diff --git a/.cargo/config b/.cargo/config index 784341b40..b4b16b881 100644 --- a/.cargo/config +++ b/.cargo/config @@ -5,4 +5,4 @@ all-test = "test --workspace" schema = "run --bin schema" [env] -RUSTFLAGS = "-C link-arg=-s" \ No newline at end of file +RUSTFLAGS = "-C link-arg=-s" diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 000000000..3453e358a --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,12 @@ +name: Cargo audit +on: + schedule: + - cron: '0 0 * * *' +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions-rs/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml new file mode 100644 index 000000000..d4b6a8256 --- /dev/null +++ b/.github/workflows/basic.yml @@ -0,0 +1,78 @@ +# Based on https://github.com/actions-rs/example/blob/master/.github/workflows/quickstart.yml + +on: [push, pull_request] + +name: Basic + +jobs: + test: + name: Test Suite + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install latest stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + target: wasm32-unknown-unknown + override: true + + - name: Run tests + uses: actions-rs/cargo@v1 + with: + toolchain: stable + command: unit-test + args: --locked + env: + RUST_BACKTRACE: 1 + + - name: Compile WASM contract + uses: actions-rs/cargo@v1 + with: + toolchain: stable + command: wasm + args: --locked + env: + RUSTFLAGS: "-C link-arg=-s" + + lints: + name: Lints + runs-on: ubuntu-latest + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + components: rustfmt, clippy + + - name: Run cargo fmt + uses: actions-rs/cargo@v1 + with: + toolchain: stable + command: fmt + args: --all -- --check + + - name: Run cargo clippy + uses: actions-rs/cargo@v1 + with: + toolchain: stable + command: clippy + args: --all-targets -- -D warnings + + - name: Generate Schema + run: ./scripts/schema.sh + + - name: Show Schema changes + run: git status --porcelain + + - name: Schema Changes + # fails if any changes not committed + run: test -z "$(git status --porcelain)" diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 000000000..9bf9308f2 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,20 @@ +name: coverage +"on": + - push +jobs: + test: + name: coverage + runs-on: ubuntu-latest + container: + image: "xd009642/tarpaulin:develop-nightly" + options: "--security-opt seccomp=unconfined" + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Generate code coverage + run: > + cargo tarpaulin --verbose --workspace --out Xml --exclude-files test-contracts/* *test*.rs packages/dao-dao-macros/* packages/dao-testing/* ci/* + - name: Upload to codecov.io + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: false diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 000000000..fca7b1d82 --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,101 @@ +name: Integration Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Integration tests + runs-on: ubuntu-latest + env: + GAS_OUT_DIR: gas_reports + GAS_LIMIT: 100000000 + steps: + - name: Checkout sources + uses: actions/checkout@v3 + + - name: Install latest nightly toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: nightly-2023-02-02 + target: wasm32-unknown-unknown + override: true + + - name: Rust Dependencies Cache + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + artifacts/ + key: ${{ runner.os }}-cargo-with-artifacts-${{ hashFiles('**/Cargo.lock') }} + + - name: Set latest just version + run: echo "JUST_VERSION=$(cargo search just -q | sed -n -e '/^just[[:space:]]/p' | cut -d '"' -f 2)" >> $GITHUB_ENV + + - name: Get cached just + uses: actions/cache@v3 + with: + path: ~/.cargo/bin/just + key: ${{ runner.os }}-just-${{ env.JUST_VERSION }} + + - name: Install just + run: cargo install just || true + + - name: Get mainnet GAS_LIMIT + run: echo "MAINNET_GAS_LIMIT=$(curl -s https://juno-rpc.polkachu.com/consensus_params | jq -r '.result.consensus_params.block.max_gas')" >> $GITHUB_ENV + + - name: Mainnet block GAS_LIMIT changed + if: ${{ env.MAINNET_GAS_LIMIT != env.GAS_LIMIT }} + uses: actions/github-script@v6 + with: + script: core.setFailed(`Integration tests must update GAS_LIMIT from ${process.env.GAS_LIMIT} to ${process.env.MAINNET_GAS_LIMIT}`) + + - name: Run Integration Tests + run: just integration-test + + - name: Combine Test Gas Reports + run: cd ci/integration-tests/ && jq -rs 'reduce .[] as $item ({}; . * $item)' gas_reports/*.json > gas_report.json + + - name: Raw Gas Report + run: cat ci/integration-tests/gas_report.json + + - name: Set GIT_BRANCH + run: echo "GIT_BRANCH=$(echo ${{ github.ref }} | sed 's|/|-|g')" >> $GITHUB_ENV + + - name: Upload Gas Report + if: ${{ github.ref == 'refs/heads/main' }} + uses: actions/upload-artifact@v3 + with: + name: dao-dao-gas-report-${{ env.GIT_BRANCH }} + path: ci/integration-tests/gas_report.json + retention-days: 90 + + - name: Download main gas report + id: download_gas + # Because the max retention period of github artifacts is 90 days + # there's a possibility the main's report no longer exists + continue-on-error: true + if: ${{ github.ref != 'refs/heads/main' }} + # NOTE: We can't use github's `actions/download-artifact` because it doesnt support + # downloading an artifact cross workflows yet + # https://github.com/actions/download-artifact/issues/3 + uses: dawidd6/action-download-artifact@v2 + with: + branch: main + workflow: integration_tests.yml + name: dao-dao-gas-report-refs-heads-main + + - name: Post gas diff to PR + if: ${{ github.ref != 'refs/heads/main' && steps.download_gas.outputs.found_artifact == 'true' }} + uses: de-husk/cosm-orc-gas-diff-action@v0.6.2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + current_json: ci/integration-tests/gas_report.json + old_json: gas_report.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..84e31c7b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# macOS +.DS_Store + +# Text file backups +**/*.rs.bk + +# Build results +target/ + +# IDEs +.vscode/ +.idea/ +*.iml +**/.editorconfig + +# Auto-gen +.cargo-ok + +# Build artifacts +*.wasm +hash.txt +contracts.txt +artifacts/ + +# code coverage +tarpaulin-report.* + +# integration tests +gas_reports/ +ci/configs/cosm-orc/local.yaml + +contracts/**/Cargo.lock +packages/**/Cargo.lock +debug/**/Cargo.lock +ci/**/Cargo.lock \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..9da9951a5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,23 @@ +# Code of conduct + +Be kind to one another. + +> Good walkers leave no track. +> Good counters don’t use their fingers. +> The best door’s unlocked and unopened. +> The best knot’s not in a rope and can’t be untied. +> +> So wise souls are good at caring for people, +> never turning their back on anyone. +> They’re good at looking after things, +> never turning their back on anything. +> There’s a light hidden here. +> +> Good people teach people who aren’t good yet; +> the less good are the makings of the good. +> Anyone who doesn’t respect a teacher +> or cherish a student +> may be clever, but has gone astray. +> There’s a deep mystery here. + +- [Tao Te Ching (Ursula Le Guin transaltion)](https://github.com/lovingawareness/tao-te-ching/blob/master/Ursula%20K%20Le%20Guin.md) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..620cd682b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,176 @@ +# Contributing to DAO DAO + +Thanks for your interest in contributing. We value kindness, humility, +and low-ego communication. You can read our [Code of +Conduct](./CODE_OF_CONDUCT.md) for a poetic version of this. + +There are many ways you can contribute. + +- If you're interested in doing smart contract work, you're in the + right place. +- If you want to work on our UI, check out the [dao-dao-ui + repo](https://github.com/DA0-DA0/dao-dao-ui). +- If you want to contribute documentation, check out our [docs + repo](https://github.com/DA0-DA0/docs). +- If you want to contribute thoughts, feedback, or ideas join the [DAO + DAO Discord](https://discord.gg/sAaGuyW3D2). + +## Getting started + +To work on the DAO DAO smart contracts you'll need a reasonable +understanding of the Rust programming language. The [Rust +book](https://doc.rust-lang.org/book/) is a really good place to pick +this up. + +Before picking up any issues, you may also want to read our [design +wiki +page](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design) +which gives an overview of how all of the DAO DAO smart contracts fit +together. + +Finally, consider reading our [security best +practices](https://github.com/DA0-DA0/dao-contracts/wiki/CosmWasm-security-best-practices) +wiki page. We won't land anything that doesn't follow those +guidelines. + +### Install Dev Dependencies + +- [Rust](https://doc.rust-lang.org/book/ch01-01-installation.html) +- [Just](https://github.com/casey/just#packages) +- [Yarn](https://yarnpkg.com/) +- [Docker](https://docs.docker.com/engine/install/) +- [jq](https://stedolan.github.io/jq/download/) +- [node](https://nodejs.org/en/download/) (>= v15) + +Our development workflow is just like a regular Rust project: + +- `cargo build` from the repository root to build the contracts. +- `cargo test` from the repository root to test the contracts. + +To build WASM files for deploying on a blockchain, run: + +```sh +just workspace-optimize +``` + +from the repository root. + +**NOTE**: If you are using VSCode and are seeing rust-analyzer "proc macro not expanded errors", you may need to add the following to your `settings.json`: + +```json +"rust-analyzer.procMacro.enable": false +``` + +## Getting ready to make a PR + +Before making a PR, you'll need to do two things to get CI passing: + +1. Generate schema files for the contracts. +2. Generate Typescript interfaces from those schemas. + +You can do both of these by running: +```sh +just gen +``` + +### Generating schema files + +We generate JSON schema files for all of our contracts' query and +execute messages. We use those schema files to generate Typescript +code for interacting with those contracts via +[ts-codegen](https://github.com/CosmWasm/ts-codegen). This generated +Typescript code is then [used in the +UI](https://github.com/DA0-DA0/dao-dao-ui/tree/40f3cbfe676a98bf7b9db7b646e74e5b2dae4502/packages/state/clients) +to interact with our contracts. Generating this code means that +frontend developers don't have to manually write query and execute +messages for each method on our contracts. + +To make sure these files are generated correctly, make sure to add any +new data types used in contract messages to `examples/schema.rs`. + +If you are adding a new query, ts-codegen expects: + +1. There is a corresponding query response type exported from + `examples/schema.rs` for that contract. +2. The query response has a name in the form `Response`. + +For example, if you added a `ListStakers {}` query, you'd also need to +make sure to export a type from the schema file called +`ListStakersResponse`. + +Most of the time, this will just be a struct with the same +name. Occasionally though you may have queries that return types like +`Vec`. In these cases you'll still need to export a type from +the schema file for this. You can do so with: + +```rust +export_schema_with_title(&schema_for!(Vec), &out_dir, "Cw20TokenListResponse"); +``` + +Once you have exported these types, you can generate schema files for +all the contracts by running: + +```sh +just gen-schema +``` + +### Generating the Typescript interface + +To generate the Typescript interface, after generating the schema +files, run: + +```sh +just gen-typescript +``` + +To do this you'll need [yarn](https://yarnpkg.com/) installed. + +If you get errors complaining about a missing query response type it +is likely because you forgot to export that type from +`examples/schema.rs` for that contract. + +## Deploying in a development environment + +Build and deploy the contracts to a local chain running in Docker with: + +```sh +just bootstrap-dev +``` + +> Note: These juno accounts are from the [test +> accounts](ci/configs/test_accounts.json), which you can use for testing (DO NOT +> store any real funds with these accounts). You can add more juno +> account addresses you wish to test here. + +This will run a chain locally in a docker container, then build and +deploy the contracts to that chain. + +The script will output something like: + +```sh +NEXT_PUBLIC_CW20_CODE_ID=1 +NEXT_PUBLIC_CW4GROUP_CODE_ID=2 +NEXT_PUBLIC_CWCORE_CODE_ID=7 +NEXT_PUBLIC_CWPROPOSALSINGLE_CODE_ID=11 +NEXT_PUBLIC_CW4VOTING_CODE_ID=5 +NEXT_PUBLIC_CW20STAKEDBALANCEVOTING_CODE_ID=4 +NEXT_PUBLIC_STAKECW20_CODE_ID=13 +NEXT_PUBLIC_DAO_CONTRACT_ADDRESS=juno1zlmaky7753d2fneyhduwz0rn3u9ns8rse3tudhze8rc2g54w9ysqgjt23l +NEXT_PUBLIC_V1_FACTORY_CONTRACT_ADDRESS=juno1pvrwmjuusn9wh34j7y520g8gumuy9xtl3gvprlljfdpwju3x7ucssml9ug +``` + +This output can be directly copied and pasted into a `.env` file in the +[DAO DAO UI](https://github.com/DA0-DA0/dao-dao-ui) for local +development. + +Note, to send commands to the docker container: + +```sh +docker exec -i cosmwasm junod status +``` + +Some commands require a password which defaults to `xxxxxxxxx`. You can use them like so: + +```sh +echo xxxxxxxxx | docker exec -i cosmwasm junod keys show validator -a +``` diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..eb8afe053 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4479 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6748e8def348ed4d14996fa801f4122cd763fff530258cdc03f64b25f89d3a5a" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "async-trait" +version = "0.1.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bip32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30ed1d6f8437a487a266c8293aeb95b61a23261273e3e02912cdb8b68bf798b" +dependencies = [ + "bs58", + "hmac", + "k256", + "once_cell", + "pbkdf2", + "rand_core 0.6.4", + "ripemd", + "sha2 0.10.7", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bnum" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "845141a4fade3f790628b7daaaa298a25b204fb28907eb54febe5142db6ce653" + +[[package]] +name = "bootstrap-env" +version = "0.2.0" +dependencies = [ + "anyhow", + "cosm-orc", + "cosmwasm-std", + "cw-admin-factory", + "cw-utils 1.0.1", + "cw20 1.1.0", + "cw20-stake 2.2.0", + "dao-dao-core", + "dao-interface", + "dao-pre-propose-single", + "dao-proposal-single", + "dao-voting 2.2.0", + "dao-voting-cw20-staked", + "env_logger", + "serde", + "serde_json", + "serde_yaml", +] + +[[package]] +name = "bs58" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" +dependencies = [ + "sha2 0.9.9", +] + +[[package]] +name = "bumpalo" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "305fe645edc1442a0fa8b6726ba61d422798d37a52e12eaecf4b022ebbb88f01" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "config" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d379af7f68bfc21714c6c7dea883544201741d2ce8274bb12fa54f89507f52a7" +dependencies = [ + "async-trait", + "json5", + "lazy_static", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust", +] + +[[package]] +name = "const-oid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + +[[package]] +name = "cosm-orc" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6676a17fddc94aac5fe07bad646d99b7f7cef1f0cc2c74b61af25733cbbb9f08" +dependencies = [ + "config", + "cosm-tome", + "erased-serde", + "log", + "serde", + "serde_json", + "thiserror", + "tokio", +] + +[[package]] +name = "cosm-tome" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "596064e3608349aa302eb68b2df8ed3a66bbb51d9b470dbd9afff70843e44642" +dependencies = [ + "async-trait", + "cosmrs", + "regex", + "schemars", + "serde", + "serde_json", + "thiserror", + "tonic 0.8.3", +] + +[[package]] +name = "cosmos-sdk-proto" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673d31bd830c0772d78545de20d975129b6ab2f7db4e4e9313c3b8777d319194" +dependencies = [ + "prost 0.11.9", + "prost-types", + "tendermint-proto 0.26.0", + "tonic 0.8.3", +] + +[[package]] +name = "cosmos-sdk-proto" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c9d2043a9e617b0d602fbc0a0ecd621568edbf3a9774890a6d562389bd8e1c" +dependencies = [ + "prost 0.11.9", + "prost-types", + "tendermint-proto 0.32.2", + "tonic 0.9.2", +] + +[[package]] +name = "cosmrs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa07096219b1817432b8f1e47c22e928c64bbfd231fc08f0a98f0e7ddd602b7" +dependencies = [ + "bip32", + "cosmos-sdk-proto 0.15.0", + "ecdsa", + "eyre", + "getrandom", + "k256", + "rand_core 0.6.4", + "serde", + "serde_json", + "subtle-encoding", + "tendermint", + "tendermint-rpc", + "thiserror", +] + +[[package]] +name = "cosmwasm-crypto" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dd316b3061747d6f57c1c4a131a5ba2f9446601a9276d05a4d25ab2ce0a7e0" +dependencies = [ + "digest 0.10.7", + "ed25519-zebra", + "k256", + "rand_core 0.6.4", + "thiserror", +] + +[[package]] +name = "cosmwasm-derive" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b14230c6942a301afb96f601af97ae09966601bd1007067a2c7fe8ffcfe303" +dependencies = [ + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-schema" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027bdd5941b7d4b45bd773b6d88818dcc043e8db68916bfbd5caf971024dbea" +dependencies = [ + "cosmwasm-schema-derive", + "schemars", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cosmwasm-schema-derive" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6e069f6e65a9a1f55f8d7423703bed35e9311d029d91b357b17a07010d95cd7" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cosmwasm-std" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c27a06f0f6c35b178563c6b1044245b3f750c4a66d9f6d2b942a6b29ad77d3ae" +dependencies = [ + "base64 0.13.1", + "bnum", + "cosmwasm-crypto", + "cosmwasm-derive", + "derivative", + "forward_ref", + "hex", + "schemars", + "serde", + "serde-json-wasm", + "sha2 0.10.7", + "thiserror", +] + +[[package]] +name = "cosmwasm-storage" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81854e8f4cb8d6d0ff956de34af56ed70c5a09cb61431dbc854982d10f8886b7" +dependencies = [ + "cosmwasm-std", + "serde", +] + +[[package]] +name = "cpufeatures" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ct-logs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a816186fa68d9e426e3cb4ae4dff1fcd8e4a2c34b781bf7a822574a0d0aac8" +dependencies = [ + "sct", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + +[[package]] +name = "cw-address-like" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451a4691083a88a3c0630a8a88799e9d4cd6679b7ce8ff22b8da2873ff31d380" +dependencies = [ + "cosmwasm-std", +] + +[[package]] +name = "cw-admin-factory" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20-base 1.1.0", + "dao-dao-core", + "dao-interface", + "thiserror", +] + +[[package]] +name = "cw-controllers" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64bfeaf55f8dba5646cc3daddce17cd23a60f8e0c3fbacbe6735d287d7a6e33a" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus 0.11.1", + "cw-utils 0.11.1", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-controllers" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f0bc6019b4d3d81e11f5c384bcce7173e2210bd654d75c6c9668e12cca05dfa" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-controllers" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d8edce4b78785f36413f67387e4be7d0cb7d032b5d4164bcc024f9c3f3f2ea" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "468b8f2696f625c8e15b5468f9420c8eabfaf23cb4fd7e6c660fc7e0cc8d77b8" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-core-interface 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cw-core-macros 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cw-paginate-storage 0.1.0", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw20 0.13.4", + "cw721 0.13.4", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-core-interface" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c93e684945473777ebed2bcaf9f0af2291653f79d5c81774c6826350ba6d88de" +dependencies = [ + "cosmwasm-std", + "cw-core-macros 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cw2 0.13.4", + "schemars", + "serde", +] + +[[package]] +name = "cw-core-interface" +version = "0.1.0" +source = "git+https://github.com/DA0-DA0/dao-contracts.git?tag=v1.0.0#e531c760a5d057329afd98d62567aaa4dca2c96f" +dependencies = [ + "cosmwasm-std", + "cw-core-macros 0.1.0 (git+https://github.com/DA0-DA0/dao-contracts.git?tag=v1.0.0)", + "cw2 0.13.4", + "schemars", + "serde", +] + +[[package]] +name = "cw-core-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f20a77489d2dc8a1c12cb0b9671b6cbdca88f12fe65e1a4ee9899490f7669dcc" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cw-core-macros" +version = "0.1.0" +source = "git+https://github.com/DA0-DA0/dao-contracts.git?tag=v1.0.0#e531c760a5d057329afd98d62567aaa4dca2c96f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cw-denom" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw20 1.1.0", + "cw20-base 1.1.0", + "thiserror", +] + +[[package]] +name = "cw-fund-distributor" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 2.2.0", + "dao-dao-core", + "dao-interface", + "dao-voting-cw20-staked", + "thiserror", +] + +[[package]] +name = "cw-hooks" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.1.0", + "thiserror", +] + +[[package]] +name = "cw-multi-test" +version = "0.16.5" +source = "git+https://github.com/JakeHartnell/cw-multi-test.git?branch=bank-supply-support#b0887db69619ea9f10b6866d07b6ae54159045c5" +dependencies = [ + "anyhow", + "cosmwasm-std", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "derivative", + "itertools 0.10.5", + "k256", + "prost 0.9.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-ownable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093dfb4520c48b5848274dd88ea99e280a04bc08729603341c7fb0d758c74321" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-address-like", + "cw-ownable-derive", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "thiserror", +] + +[[package]] +name = "cw-ownable-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d3bf2e0f341bb6cc100d7d441d31cf713fbd3ce0c511f91e79f14b40a889af" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cw-paginate-storage" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b854833e07c557dee02d1b61a21bb0731743bb2e3bbdc3e446a0d8a38af40ec4" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-storage-plus 0.13.4", + "serde", +] + +[[package]] +name = "cw-paginate-storage" +version = "2.2.0" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "serde", +] + +[[package]] +name = "cw-payroll-factory" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw-vesting", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "thiserror", + "wynd-utils", +] + +[[package]] +name = "cw-proposal-single" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6408483e1ac17a7e2b98ef6fa1379776964353bcbf501942d22ee1c1323117" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-core", + "cw-core-interface 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cw-core-macros 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw20 0.13.4", + "cw3 0.13.4", + "dao-voting 0.1.0", + "indexable-hooks", + "proposal-hooks", + "schemars", + "serde", + "thiserror", + "vote-hooks", +] + +[[package]] +name = "cw-stake-tracker" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-wormhole", +] + +[[package]] +name = "cw-storage-plus" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d7ee1963302b0ac2a9d42fe0faec826209c17452bfd36fbfd9d002a88929261" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "cw-storage-plus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "648b1507290bbc03a8d88463d7cd9b04b1fa0155e5eef366c4fa052b9caaac7a" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "cw-storage-plus" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b6f91c0b94481a3e9ef1ceb183c37d00764f8751e39b45fc09f4d9b970d469" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "cw-storage-plus" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f0e92a069d62067f3472c62e30adedb4cab1754725c0f2a682b3128d2bf3c79" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "cw-token-swap" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "thiserror", +] + +[[package]] +name = "cw-utils" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef842a1792e4285beff7b3b518705f760fa4111dc1e296e53f3e92d1ef7f6220" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-utils" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dbaecb78c8e8abfd6b4258c7f4fbeb5c49a5e45ee4d910d3240ee8e1d714e1b" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-utils" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a84c6c1c0acc3616398eba50783934bd6c964bad6974241eaee3460c8f5b26" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw2 0.16.0", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "cw-utils" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c80e93d1deccb8588db03945016a292c3c631e6325d349ebb35d2db6f4f946f7" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw2 1.1.0", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "cw-vesting" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-multi-test", + "cw-ownable", + "cw-paginate-storage 2.2.0", + "cw-stake-tracker", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw-wormhole", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "dao-testing", + "serde", + "thiserror", + "wynd-utils", +] + +[[package]] +name = "cw-wormhole" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.1.0", + "serde", +] + +[[package]] +name = "cw2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d81d7c359d6c1fba3aa83dad7ec6f999e512571380ae62f81257c3db569743" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus 0.11.1", + "schemars", + "serde", +] + +[[package]] +name = "cw2" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cf4639517490dd36b333bbd6c4fbd92e325fd0acf4683b41753bc5eb63bfc1" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus 0.13.4", + "schemars", + "serde", +] + +[[package]] +name = "cw2" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91398113b806f4d2a8d5f8d05684704a20ffd5968bf87e3473e1973710b884ad" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 0.16.0", + "schemars", + "serde", +] + +[[package]] +name = "cw2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ac2dc7a55ad64173ca1e0a46697c31b7a5c51342f55a1e84a724da4eb99908" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.1.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw20" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9671d7edef5608acaf5b2f1e473ee3f501eced2cd4f7392e2106c8cf02ba0720" +dependencies = [ + "cosmwasm-std", + "cw-utils 0.11.1", + "schemars", + "serde", +] + +[[package]] +name = "cw20" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb782b8f110819a4eb5dbbcfed25ffba49ec16bbe32b4ad8da50a5ce68fec05" +dependencies = [ + "cosmwasm-std", + "cw-utils 0.13.4", + "schemars", + "serde", +] + +[[package]] +name = "cw20" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011c45920f8200bd5d32d4fe52502506f64f2f75651ab408054d4cfc75ca3a9b" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils 1.0.1", + "schemars", + "serde", +] + +[[package]] +name = "cw20-base" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f6fc8c4cd451b418fa4f1ac2ea70595811fa9d8b4033617fe47953d7a93ceb" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus 0.11.1", + "cw-utils 0.11.1", + "cw2 0.11.1", + "cw20 0.11.1", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw20-base" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0306e606581f4fb45e82bcbb7f0333179ed53dd949c6523f01a99b4bfc1475a0" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw20 0.13.4", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw20-base" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3ad456059901a36cfa68b596d85d579c3df2b797dae9950dc34c27e14e995f" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "cw20-stake" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f0d51ce27a97b51f66d737183845bc6d82f46f4b246dc959d1265d86906ccc" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-controllers 0.13.4", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw20 0.13.4", + "cw20-base 0.13.4", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw20-stake" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-controllers 1.1.0", + "cw-multi-test", + "cw-ownable", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 0.13.4", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 0.2.6", + "thiserror", +] + +[[package]] +name = "cw20-stake-external-rewards" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-controllers 1.1.0", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 0.13.4", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 2.2.0", + "stake-cw20-external-rewards", + "thiserror", +] + +[[package]] +name = "cw20-stake-reward-distributor" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 2.2.0", + "stake-cw20-reward-distributor", + "thiserror", +] + +[[package]] +name = "cw20-staked-balance-voting" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf8c2ee92372d35c3a48fd6ddd490a1a4426902748017dd0b7f551d06484e28" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-core-interface 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cw-core-macros 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw20 0.13.4", + "cw20-base 0.13.4", + "cw20-stake 0.2.6", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw3" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe19462a7f644ba60c19d3443cb90d00c50d9b6b3b0a3a7fca93df8261af979b" +dependencies = [ + "cosmwasm-std", + "cw-utils 0.13.4", + "schemars", + "serde", +] + +[[package]] +name = "cw3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171af3d9127de6805a7dd819fb070c7d2f6c3ea85f4193f42cef259f0a7f33d5" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils 1.0.1", + "cw20 1.1.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw4" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0acc3549d5ce11c6901b3a676f2e2628684722197054d97cd0101ea174ed5cbd" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus 0.13.4", + "schemars", + "serde", +] + +[[package]] +name = "cw4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a398696307efadaaa2d0850076f865fa706c959d493cb4203314f72be6b77a64" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.1.0", + "schemars", + "serde", +] + +[[package]] +name = "cw4-group" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c95c89153e7831c8306c8eba40a3daa76f9c7b8f5179dd0b8628aca168ec7a" +dependencies = [ + "cosmwasm-std", + "cw-controllers 0.13.4", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw4 0.13.4", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw4-group" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58db332c4039bec8ade6aaa1f5fff24b94b111f21134db172cd27fb2e7f0ceb6" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw4 1.1.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw4-voting" +version = "0.1.0" +source = "git+https://github.com/DA0-DA0/dao-contracts.git?tag=v1.0.0#e531c760a5d057329afd98d62567aaa4dca2c96f" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-core-interface 0.1.0 (git+https://github.com/DA0-DA0/dao-contracts.git?tag=v1.0.0)", + "cw-core-macros 0.1.0 (git+https://github.com/DA0-DA0/dao-contracts.git?tag=v1.0.0)", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw4 0.13.4", + "cw4-group 0.13.4", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw721" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "035818368a74c07dd9ed5c5a93340199ba251530162010b9f34c3809e3b97df1" +dependencies = [ + "cosmwasm-std", + "cw-utils 0.13.4", + "schemars", + "serde", +] + +[[package]] +name = "cw721" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a1ea6e6277bdd6dfc043a9b1380697fe29d6e24b072597439523658d21d791" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils 0.16.0", + "schemars", + "serde", +] + +[[package]] +name = "cw721" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c4d286625ccadc957fe480dd3bdc54ada19e0e6b5b9325379db3130569e914" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils 1.0.1", + "schemars", + "serde", +] + +[[package]] +name = "cw721-base" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77518e27431d43214cff4cdfbd788a7508f68d9b1f32389e6fce513e7eaccbef" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 0.16.0", + "cw-utils 0.16.0", + "cw2 0.16.0", + "cw721 0.16.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw721-base" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da518d9f68bfda7d972cbaca2e8fcf04651d0edc3de72b04ae2bcd9289c81614" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-ownable", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw721 0.18.0", + "cw721-base 0.16.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw721-controllers" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "thiserror", +] + +[[package]] +name = "cw721-roles" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.0", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw4 1.1.0", + "cw721 0.18.0", + "cw721-base 0.18.0", + "dao-cw721-extensions", + "dao-testing", + "dao-voting-cw721-staked", + "serde", + "thiserror", +] + +[[package]] +name = "dao-cw721-extensions" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.0", + "cw4 1.1.0", +] + +[[package]] +name = "dao-dao-core" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-core", + "cw-multi-test", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw721 0.18.0", + "cw721-base 0.18.0", + "dao-dao-macros", + "dao-interface", + "dao-proposal-sudo", + "dao-voting-cw20-balance", + "thiserror", +] + +[[package]] +name = "dao-dao-macros" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-hooks", + "dao-interface", + "dao-voting 2.2.0", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "dao-interface" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-hooks", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw721 0.18.0", +] + +[[package]] +name = "dao-migrator" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-core", + "cw-core-interface 0.1.0 (git+https://github.com/DA0-DA0/dao-contracts.git?tag=v1.0.0)", + "cw-multi-test", + "cw-proposal-single", + "cw-storage-plus 1.1.0", + "cw-utils 0.13.4", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 0.13.4", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 0.2.6", + "cw20-stake 2.2.0", + "cw20-staked-balance-voting", + "cw4 0.13.4", + "cw4-voting", + "dao-dao-core", + "dao-interface", + "dao-proposal-single", + "dao-testing", + "dao-voting 0.1.0", + "dao-voting 2.2.0", + "dao-voting-cw20-staked", + "dao-voting-cw4", + "thiserror", +] + +[[package]] +name = "dao-pre-propose-approval-single" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-multi-test", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw4-group 1.1.0", + "dao-dao-core", + "dao-interface", + "dao-pre-propose-base", + "dao-proposal-hooks", + "dao-proposal-single", + "dao-testing", + "dao-voting 2.2.0", + "dao-voting-cw20-staked", + "dao-voting-cw4", + "thiserror", +] + +[[package]] +name = "dao-pre-propose-approver" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw4-group 1.1.0", + "dao-dao-core", + "dao-interface", + "dao-pre-propose-approval-single", + "dao-pre-propose-base", + "dao-proposal-hooks", + "dao-proposal-single", + "dao-testing", + "dao-voting 2.2.0", + "dao-voting-cw20-staked", + "dao-voting-cw4", +] + +[[package]] +name = "dao-pre-propose-base" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-hooks", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "dao-interface", + "dao-proposal-hooks", + "dao-voting 2.2.0", + "serde", + "thiserror", +] + +[[package]] +name = "dao-pre-propose-multiple" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-multi-test", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw4-group 1.1.0", + "dao-dao-core", + "dao-interface", + "dao-pre-propose-base", + "dao-proposal-hooks", + "dao-proposal-multiple", + "dao-testing", + "dao-voting 2.2.0", + "dao-voting-cw20-staked", + "dao-voting-cw4", +] + +[[package]] +name = "dao-pre-propose-single" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-hooks", + "cw-multi-test", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw4-group 1.1.0", + "dao-dao-core", + "dao-interface", + "dao-pre-propose-base", + "dao-proposal-hooks", + "dao-proposal-single", + "dao-testing", + "dao-voting 2.2.0", + "dao-voting-cw20-staked", + "dao-voting-cw4", +] + +[[package]] +name = "dao-proposal-condorcet" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw4 1.1.0", + "cw4-group 1.1.0", + "dao-dao-core", + "dao-dao-macros", + "dao-interface", + "dao-testing", + "dao-voting 2.2.0", + "dao-voting-cw4", + "thiserror", +] + +[[package]] +name = "dao-proposal-hook-counter" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-hooks", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "dao-dao-core", + "dao-interface", + "dao-proposal-hooks", + "dao-proposal-single", + "dao-vote-hooks", + "dao-voting 2.2.0", + "dao-voting-cw20-balance", + "thiserror", +] + +[[package]] +name = "dao-proposal-hooks" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-hooks", + "dao-voting 2.2.0", +] + +[[package]] +name = "dao-proposal-multiple" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-denom", + "cw-hooks", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 2.2.0", + "cw3 1.1.0", + "cw4 1.1.0", + "cw4-group 1.1.0", + "cw721-base 0.18.0", + "dao-dao-macros", + "dao-interface", + "dao-pre-propose-base", + "dao-pre-propose-multiple", + "dao-proposal-hooks", + "dao-testing", + "dao-vote-hooks", + "dao-voting 0.1.0", + "dao-voting 2.2.0", + "dao-voting-cw20-balance", + "dao-voting-cw20-staked", + "dao-voting-cw4", + "dao-voting-cw721-staked", + "dao-voting-native-staked", + "rand", + "thiserror", +] + +[[package]] +name = "dao-proposal-single" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-core", + "cw-denom", + "cw-hooks", + "cw-multi-test", + "cw-proposal-single", + "cw-storage-plus 1.1.0", + "cw-utils 0.13.4", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 2.2.0", + "cw3 1.1.0", + "cw4 1.1.0", + "cw4-group 1.1.0", + "cw721-base 0.18.0", + "dao-dao-core", + "dao-dao-macros", + "dao-interface", + "dao-pre-propose-base", + "dao-pre-propose-single", + "dao-proposal-hooks", + "dao-testing", + "dao-vote-hooks", + "dao-voting 0.1.0", + "dao-voting 2.2.0", + "dao-voting-cw20-balance", + "dao-voting-cw20-staked", + "dao-voting-cw4", + "dao-voting-cw721-staked", + "dao-voting-native-staked", + "thiserror", +] + +[[package]] +name = "dao-proposal-sudo" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw2 1.1.0", + "dao-dao-macros", + "dao-interface", + "thiserror", +] + +[[package]] +name = "dao-testing" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-core", + "cw-hooks", + "cw-multi-test", + "cw-proposal-single", + "cw-utils 1.0.1", + "cw-vesting", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 2.2.0", + "cw4 1.1.0", + "cw4-group 1.1.0", + "cw721-base 0.18.0", + "cw721-roles", + "dao-dao-core", + "dao-interface", + "dao-pre-propose-multiple", + "dao-pre-propose-single", + "dao-proposal-condorcet", + "dao-proposal-single", + "dao-voting 0.1.0", + "dao-voting 2.2.0", + "dao-voting-cw20-balance", + "dao-voting-cw20-staked", + "dao-voting-cw4", + "dao-voting-cw721-roles", + "dao-voting-cw721-staked", + "dao-voting-native-staked", + "rand", + "stake-cw20", + "token-bindings 0.10.3 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "dao-vote-hooks" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-hooks", + "dao-voting 2.2.0", +] + +[[package]] +name = "dao-voting" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442d770933e3b3ecab4cfb4d6e9d054082b007d35fda3cf0c3d3ddd1cfa91782" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "dao-voting" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw20 1.1.0", + "dao-dao-macros", + "dao-interface", + "thiserror", +] + +[[package]] +name = "dao-voting-cw20-balance" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "dao-dao-macros", + "dao-interface", + "thiserror", +] + +[[package]] +name = "dao-voting-cw20-staked" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 2.2.0", + "dao-dao-macros", + "dao-interface", + "dao-voting 2.2.0", + "thiserror", +] + +[[package]] +name = "dao-voting-cw4" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-multi-test", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw4 1.1.0", + "cw4-group 1.1.0", + "dao-dao-macros", + "dao-interface", + "thiserror", +] + +[[package]] +name = "dao-voting-cw721-roles" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-ownable", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw4 1.1.0", + "cw721 0.18.0", + "cw721-base 0.18.0", + "cw721-controllers", + "cw721-roles", + "dao-cw721-extensions", + "dao-dao-macros", + "dao-interface", + "dao-testing", + "thiserror", +] + +[[package]] +name = "dao-voting-cw721-staked" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.0", + "cw-multi-test", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "cw721 0.18.0", + "cw721-base 0.18.0", + "cw721-controllers", + "dao-dao-macros", + "dao-interface", + "dao-testing", + "dao-voting 2.2.0", + "thiserror", +] + +[[package]] +name = "dao-voting-native-staked" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-controllers 1.1.0", + "cw-hooks", + "cw-multi-test", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "dao-dao-macros", + "dao-interface", + "dao-voting 2.2.0", + "thiserror", +] + +[[package]] +name = "dao-voting-token-factory-staked" +version = "2.2.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-controllers 1.1.0", + "cw-hooks", + "cw-multi-test", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "dao-dao-macros", + "dao-interface", + "dao-voting 2.2.0", + "thiserror", + "token-bindings 0.10.3 (git+https://github.com/CosmosContracts/token-bindings.git?branch=update-token-bindings-test)", + "token-bindings-test", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] + +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + +[[package]] +name = "dyn-clone" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfc4744c1b8f2a09adc0e55242f60b1af195d88596bd8700be74418c056c555" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762bae6dcaf24c4c84667b8579785430908723d5c889f469d76a41d59cc7a9d" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "ed25519-zebra" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c24f403d068ad0b359e577a77f92392118be3f3c927538f2bb544a5ecd828c6" +dependencies = [ + "curve25519-dalek", + "hashbrown 0.12.3", + "hex", + "rand_core 0.6.4", + "serde", + "sha2 0.9.9", + "zeroize", +] + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint", + "der", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "env_logger" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "erased-serde" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc978899517288e3ebbd1a3bfc1d9537dbb87eeab149e53ea490e63bcdff561a" +dependencies = [ + "serde", +] + +[[package]] +name = "errno" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b30f669a7961ef1631673d2766cc92f52d64f7ef354d4fe0ddfd30ed52f0f4f" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "eyre" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2b6b5a29c02cdc822728b7d7b8ae1bab3e3b05d44522770ddd49722eeac7eb" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "flex-error" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c606d892c9de11507fa0dcffc116434f94e105d0bbdc4e405b61519464c49d7b" +dependencies = [ + "eyre", + "paste", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forward_ref" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cbd1169bd7b4a0a20d92b9af7a7e0422888bd38a6f5ec29c1fd8c1558a272e" + +[[package]] +name = "futures" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c" + +[[package]] +name = "futures-executor" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964" + +[[package]] +name = "futures-macro" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "futures-sink" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e" + +[[package]] +name = "futures-task" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65" + +[[package]] +name = "futures-util" +version = "0.3.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap 1.9.3", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" + +[[package]] +name = "headers" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584" +dependencies = [ + "base64 0.13.1", + "bitflags 1.3.2", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http", +] + +[[package]] +name = "hermit-abi" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.4.9", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-proxy" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca815a891b24fdfb243fa3239c86154392b0953ee584aa1a2a1f66d20cbe75cc" +dependencies = [ + "bytes", + "futures", + "headers", + "http", + "hyper", + "hyper-rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki", +] + +[[package]] +name = "hyper-rustls" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f9f7a97316d44c0af9b0301e65010573a853a9fc97046d7331d7f6bc0fd5a64" +dependencies = [ + "ct-logs", + "futures-util", + "hyper", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "webpki", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + +[[package]] +name = "indexable-hooks" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d70922e1e0e68d99ec1a24446c70756cc3e56deaddb505b1f4b43914522d809" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus 0.13.4", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d" +dependencies = [ + "equivalent", + "hashbrown 0.14.0", +] + +[[package]] +name = "integration-tests" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_matches", + "cosm-orc", + "cosm-tome", + "cosmos-sdk-proto 0.19.0", + "cosmwasm-std", + "cw-utils 1.0.1", + "cw-vesting", + "cw20 1.1.0", + "cw20-base 1.1.0", + "cw20-stake 2.2.0", + "cw721 0.18.0", + "cw721-base 0.18.0", + "cw721-roles", + "dao-dao-core", + "dao-interface", + "dao-pre-propose-single", + "dao-proposal-single", + "dao-voting 2.2.0", + "dao-voting-cw20-staked", + "dao-voting-cw721-staked", + "env_logger", + "once_cell", + "rand", + "serde", + "serde_json", + "test-context", +] + +[[package]] +name = "is-terminal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +dependencies = [ + "hermit-abi", + "rustix", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" + +[[package]] +name = "js-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "k256" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c1e0b51e7ec0a97369623508396067a486bd0cbed95a2659a4b863d28cfc8b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2 0.10.7", + "sha3", +] + +[[package]] +name = "keccak" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f6d5ed8676d904364de097082f4e7d240b571b67989ced0240f08b7f966f940" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503" + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "matchit" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-traits" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "peg" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c0b841ea54f523f7aa556956fbd293bcbe06f2e67d2eb732b7278aaf1d166a" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aa52829b8decbef693af90202711348ab001456803ba2a98eb4ec8fb70844c" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c719dcf55f09a3a7e764c6649ab594c18a177e3599c467983cdf644bfc0a4088" + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + +[[package]] +name = "pest" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1acb4a4365a13f749a93f1a094a7805e5cfa0955373a9de860d962eaa3a5fe5a" +dependencies = [ + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666d00490d4ac815001da55838c500eafb0320019bbaa44444137c48b443a853" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ca01446f50dbda87c1786af8770d535423fa8a53aec03b8f4e3d7eb10e0929" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "pest_meta" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56af0a30af74d0445c0bf6d9d051c979b516a1a5af790d251daee76005420a48" +dependencies = [ + "once_cell", + "pest", + "sha2 0.10.7", +] + +[[package]] +name = "pin-project" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda4ed1c6c173e3fc7a83629421152e01d7b1f9b7f65fb301e490e8cfc656422" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proposal-hooks" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a2f15b848398bad689771b35313c7e7095e772d444e299dbdb54b906691f8a" +dependencies = [ + "cosmwasm-std", + "indexable-hooks", + "schemars", + "serde", +] + +[[package]] +name = "prost" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444879275cb4fd84958b1a1d5420d15e6fcf7c235fe47f053c9c2a80aceb6001" +dependencies = [ + "bytes", + "prost-derive 0.9.0", +] + +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive 0.11.9", +] + +[[package]] +name = "prost-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cc1a3263e07e0bf68e96268f37665207b49560d98739662cdfaae215c720fe" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "prost-types" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" +dependencies = [ + "prost 0.11.9", +] + +[[package]] +name = "quote" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint", + "hmac", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "ripemd160" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eca4ecc81b7f313189bf73ce724400a07da2a6dac19588b03c8bd76a2dcc251" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "ron" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +dependencies = [ + "base64 0.13.1", + "bitflags 1.3.2", + "serde", +] + +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustix" +version = "0.38.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ed4fa021d81c8392ce04db050a3da9a60299050b7ae1cf482d862b54a7218f" +dependencies = [ + "bitflags 2.4.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +dependencies = [ + "base64 0.13.1", + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rustls-native-certs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07b7c1885bd8ed3831c289b7870b13ef46fe0e856d288c30d9cc17d75a2092" +dependencies = [ + "openssl-probe", + "rustls", + "schannel", + "security-framework", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "ryu" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "schemars" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c613288622e5f0c3fdc5dbd4db1c5fbe752746b1d1a56a0630b78fd00de44f" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109da1e6b197438deb6db99952990c7f959572794b80ff93707d55a232545e7c" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 1.0.109", +] + +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918" + +[[package]] +name = "serde" +version = "1.0.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ac8da02677876d532745a130fc9d8e6edfa81a269b107c5b00829b91d8eb3c" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-json-wasm" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a62a1fad1e1828b24acac8f2b468971dade7b8c3c2e672bcadefefb1f8c137" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab33ec92f677585af6d88c65593ae2375adde54efdbf16d597f2cbc7a6d368ff" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafe972d60b0b9bee71a91b92fee2d4fb3c9d7e8f6b179aa99f27203d99a4816" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "serde_derive_internals" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "serde_json" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "serde_yaml" +version = "0.9.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a49e178e4452f45cb61d0cd8cebc1b0fafd3e41929e996cef79aa3aca91f574" +dependencies = [ + "indexmap 2.0.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d" +dependencies = [ + "autocfg", +] + +[[package]] +name = "socket2" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stake-cw20" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfbd45133276dbe4d6588899f4d4d06fdb9f16921fd1394affc0bccc9a5cb0b6" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-controllers 0.11.1", + "cw-storage-plus 0.11.1", + "cw-utils 0.11.1", + "cw2 0.11.1", + "cw20 0.11.1", + "cw20-base 0.11.1", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "stake-cw20-external-rewards" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9bbc1e4b7a932957a05a76921015a849b234c3f25e59fe1fd0d2eab71654bc" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-controllers 0.13.4", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw20 0.13.4", + "cw20-base 0.13.4", + "cw20-stake 0.2.6", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "stake-cw20-reward-distributor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4260ff7aec6dddb43cb5f1104ef5cebe2787853bc83af9172ce5b828b577c4c5" +dependencies = [ + "cosmwasm-std", + "cosmwasm-storage", + "cw-storage-plus 0.13.4", + "cw-utils 0.13.4", + "cw2 0.13.4", + "cw20 0.13.4", + "cw20-base 0.13.4", + "cw20-stake 0.2.6", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "subtle-encoding" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcb1ed7b8330c5eed5441052651dd7a12c75e2ed88f2ec024ae1fa3a5e59945" +dependencies = [ + "zeroize", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "tendermint" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa1d2d0ec1b531ba7d196f0dbee5e78ed2a82bfba928e88dff64aeec0b26073" +dependencies = [ + "async-trait", + "bytes", + "ed25519", + "ed25519-dalek", + "flex-error", + "futures", + "k256", + "num-traits", + "once_cell", + "prost 0.11.9", + "prost-types", + "ripemd160", + "serde", + "serde_bytes", + "serde_json", + "serde_repr", + "sha2 0.9.9", + "signature", + "subtle", + "subtle-encoding", + "tendermint-proto 0.26.0", + "time", + "zeroize", +] + +[[package]] +name = "tendermint-config" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202a2f19502c03b353d8157694ed24fbc58c3dd64a92a5b0cb80b79c82af5be4" +dependencies = [ + "flex-error", + "serde", + "serde_json", + "tendermint", + "toml", + "url", +] + +[[package]] +name = "tendermint-proto" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "974d6330a19dfa6720e9f663fc59101d207a817db3f9c730d3f31caaa565b574" +dependencies = [ + "bytes", + "flex-error", + "num-derive", + "num-traits", + "prost 0.11.9", + "prost-types", + "serde", + "serde_bytes", + "subtle-encoding", + "time", +] + +[[package]] +name = "tendermint-proto" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cec054567d16d85e8c3f6a3139963d1a66d9d3051ed545d31562550e9bcc3d" +dependencies = [ + "bytes", + "flex-error", + "num-derive", + "num-traits", + "prost 0.11.9", + "prost-types", + "serde", + "serde_bytes", + "subtle-encoding", + "time", +] + +[[package]] +name = "tendermint-rpc" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5d87fa5429bd2ee39c4809dd546096daf432de9b71157bc12c182ab5bae7ea7" +dependencies = [ + "async-trait", + "bytes", + "flex-error", + "futures", + "getrandom", + "http", + "hyper", + "hyper-proxy", + "hyper-rustls", + "peg", + "pin-project", + "serde", + "serde_bytes", + "serde_json", + "subtle", + "subtle-encoding", + "tendermint", + "tendermint-config", + "tendermint-proto 0.26.0", + "thiserror", + "time", + "tokio", + "tracing", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "test-context" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "055831a02a4f5aa28fede67f2902014273eb8c21b958ac5ebbd59b71ef30dbc3" +dependencies = [ + "async-trait", + "futures", + "test-context-macros", +] + +[[package]] +name = "test-context-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901a55b0a7a06ebc4a674dcca925170da8e613fa3b163a1df804ed10afb154d" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "thiserror" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "time" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217" +dependencies = [ + "libc", + "num_threads", + "time-macros", +] + +[[package]] +name = "time-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "token-bindings" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "752a7997ebaa191cf3d8436261e449732e65268e552e8bea6133c3b21b48fe36" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "token-bindings" +version = "0.10.3" +source = "git+https://github.com/CosmosContracts/token-bindings.git?branch=update-token-bindings-test#9c16f5fb2fa1beb6c85b27982adc7520017f7426" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "token-bindings-test" +version = "0.9.0" +source = "git+https://github.com/CosmosContracts/token-bindings.git?branch=update-token-bindings-test#9c16f5fb2fa1beb6c85b27982adc7520017f7426" +dependencies = [ + "anyhow", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 0.16.0", + "itertools 0.11.0", + "schemars", + "serde", + "thiserror", + "token-bindings 0.10.3 (git+https://github.com/CosmosContracts/token-bindings.git?branch=update-token-bindings-test)", +] + +[[package]] +name = "tokio" +version = "1.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2 0.5.3", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "tokio-rustls" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +dependencies = [ + "rustls", + "tokio", + "webpki", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tonic" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f219fad3b929bef19b1f86fbc0358d35daed8f2cac972037ac0dc10bbb8d5fb" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64 0.13.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost 0.11.9", + "prost-derive 0.11.9", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "tracing-futures", +] + +[[package]] +name = "tonic" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3082666a3a6433f7f511c7192923fa1fe07c69332d3c6a2e6bb040b569199d5a" +dependencies = [ + "async-trait", + "axum", + "base64 0.21.2", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost 0.11.9", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + +[[package]] +name = "tracing-core" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "try-lock" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" + +[[package]] +name = "typenum" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" + +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + +[[package]] +name = "unicode-bidi" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" + +[[package]] +name = "unicode-ident" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28467d3e1d3c6586d8f25fa243f544f5800fec42d97032474e17222c2b75cfa" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vote-hooks" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef617ad17edd195f8a3bce72498bfcc406a27cecfc23828f562fa91a3e2fb141" +dependencies = [ + "cosmwasm-std", + "indexable-hooks", + "schemars", + "serde", +] + +[[package]] +name = "walkdir" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.29", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1" + +[[package]] +name = "web-sys" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" +dependencies = [ + "webpki", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f51fb4c64f8b770a823c043c7fad036323e1c48f55287b7bbb7987b2fcdf3b" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde1bb55ae4ce76a597a8566d82c57432bc69c039449d61572a7a353da28f68c" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1513e8d48365a78adad7322fd6b5e4c4e99d92a69db8df2d435b25b1f1f286d4" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60587c0265d2b842298f5858e1a5d79d146f9ee0c37be5782e92a6eb5e1d7a83" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224fe0e0ffff5d2ea6a29f82026c8f43870038a0ffc247aa95a52b47df381ac4" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62fc52a0f50a088de499712cbc012df7ebd94e2d6eb948435449d76a6287e7ad" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2093925509d91ea3d69bcd20238f4c2ecdb1a29d3c281d026a09705d0dd35f3d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ade45bc8bf02ae2aa34a9d54ba660a1a58204da34ba793c00d83ca3730b5f1" + +[[package]] +name = "wynd-utils" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa37b3fba808df599acc6f0d7523b465baf47a0b0361867c4f1635eb53f72aa" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..da26f2bc7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,121 @@ +[workspace] +members = [ + "contracts/dao-dao-core", + "contracts/external/*", + "contracts/proposal/*", + "contracts/pre-propose/*", + "contracts/staking/*", + "contracts/voting/*", + "packages/*", + "test-contracts/*", + "ci/*", +] +exclude = ["ci/configs/"] + +[workspace.package] +edition = "2021" +license = "BSD-3-Clause" +repository = "https://github.com/DA0-DA0/dao-contracts" +version = "2.2.0" + +[profile.release] +codegen-units = 1 +opt-level = 3 +debug = false +rpath = false +lto = true +debug-assertions = false +panic = 'abort' +incremental = false +# Please do not disable these. Doing so will cause overflow checks in +# all workspace members to stop working. Overflows should be errors. +overflow-checks = true + +[workspace.dependencies] +anyhow = { version = "1.0" } +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.2", features = ["ibc3"] } +cosmwasm-storage = { version = "1.2" } +cw-controllers = "1.1" +# TODO use upstream when PR merged: https://github.com/CosmWasm/cw-multi-test/pull/51 +cw-multi-test = { git = "https://github.com/JakeHartnell/cw-multi-test.git", branch = "bank-supply-support" } +cw-storage-plus = { version = "1.1" } +cw-utils = "1.0" +cw2 = "1.1" +cw20 = "1.1" +cw20-base = "1.1" +cw3 = "1.1" +cw4 = "1.1" +cw4-group = "1.1" +cw721 = "0.18" +cw721-base = "0.18" +env_logger = "0.10" +once_cell = "1.18" +proc-macro2 = "1.0" +quote = "1.0" +rand = "0.8" +serde = { version = "1.0", default-features = false, features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +syn = { version = "1.0", features = ["derive"] } +test-context = "0.1" +thiserror = { version = "1.0" } +token-bindings = { git = "https://github.com/CosmosContracts/token-bindings.git", branch = "update-token-bindings-test" } +token-bindings-test = { git = "https://github.com/CosmosContracts/token-bindings.git", branch = "update-token-bindings-test" } +wynd-utils = "0.4" + +# One commit ahead of version 0.3.0. Allows initialization with an +# optional owner. +cw-ownable = "0.5" + +cw-admin-factory = { path = "./contracts/external/cw-admin-factory", version = "2.2.0" } +cw-denom = { path = "./packages/cw-denom", version = "2.2.0" } +cw-hooks = { path = "./packages/cw-hooks", version = "2.2.0" } +cw-wormhole = { path = "./packages/cw-wormhole", version = "2.2.0" } +cw-paginate-storage = { path = "./packages/cw-paginate-storage", version = "2.2.0" } +cw-payroll-factory = { path = "./contracts/external/cw-payroll-factory", version = "2.2.0" } +cw-vesting = { path = "./contracts/external/cw-vesting", version = "2.2.0" } +cw20-stake = { path = "./contracts/staking/cw20-stake", version = "2.2.0" } +cw-stake-tracker = { path = "./packages/cw-stake-tracker", version = "2.2.0" } +cw721-controllers = { path = "./packages/cw721-controllers", version = "2.2.0" } +cw721-roles = { path = "./contracts/external/cw721-roles", version = "*" } +dao-cw721-extensions = { path = "./packages/dao-cw721-extensions", version = "*" } +dao-dao-core = { path = "./contracts/dao-dao-core", version = "2.2.0" } +dao-interface = { path = "./packages/dao-interface", version = "2.2.0" } +dao-dao-macros = { path = "./packages/dao-dao-macros", version = "2.2.0" } +dao-pre-propose-approval-single = { path = "./contracts/pre-propose/dao-pre-propose-approval-single", version = "2.2.0" } +dao-pre-propose-approver = { path = "./contracts/pre-propose/dao-pre-propose-approver", version = "2.2.0" } +dao-pre-propose-base = { path = "./packages/dao-pre-propose-base", version = "2.2.0" } +dao-pre-propose-multiple = { path = "./contracts/pre-propose/dao-pre-propose-multiple", version = "2.2.0" } +dao-pre-propose-single = { path = "./contracts/pre-propose/dao-pre-propose-single", version = "2.2.0" } +dao-proposal-condorcet = { path = "./contracts/proposal/dao-proposal-condorcet", version = "2.2.0" } +dao-proposal-hooks = { path = "./packages/dao-proposal-hooks", version = "2.2.0" } +dao-proposal-multiple = { path = "./contracts/proposal/dao-proposal-multiple", version = "2.2.0" } +dao-proposal-single = { path = "./contracts/proposal/dao-proposal-single", version = "2.2.0" } +dao-proposal-sudo = { path = "./test-contracts/dao-proposal-sudo", version = "2.2.0" } +dao-testing = { path = "./packages/dao-testing", version = "2.2.0" } +dao-vote-hooks = { path = "./packages/dao-vote-hooks", version = "2.2.0" } +dao-voting = { path = "./packages/dao-voting", version = "2.2.0" } +dao-voting-cw20-balance = { path = "./test-contracts/dao-voting-cw20-balance", version = "2.2.0" } +dao-voting-cw20-staked = { path = "./contracts/voting/dao-voting-cw20-staked", version = "2.2.0" } +dao-voting-cw4 = { path = "./contracts/voting/dao-voting-cw4", version = "2.2.0" } +dao-voting-cw721-roles = { path = "./contracts/voting/dao-voting-cw721-roles", version = "*" } +dao-voting-cw721-staked = { path = "./contracts/voting/dao-voting-cw721-staked", version = "2.2.0" } +dao-voting-native-staked = { path = "./contracts/voting/dao-voting-native-staked", version = "2.2.0" } +dao-voting-token-factory-staked = { path = "./contracts/voting/dao-voting-token-factory-staked", version = "2.2.0" } + +# v1 dependencies. used for state migrations. +cw-core-v1 = { package = "cw-core", version = "0.1.0" } +cw-proposal-single-v1 = { package = "cw-proposal-single", version = "0.1.0" } +cw-utils-v1 = { package = "cw-utils", version = "0.13" } +cw20-stake-external-rewards-v1 = { package = "stake-cw20-external-rewards", version = "0.2.6" } +cw20-stake-reward-distributor-v1 = { package = "stake-cw20-reward-distributor", version = "0.1.0" } +cw20-stake-v1 = { package = "cw20-stake", version = "0.2.6" } +cw20-staked-balance-voting-v1 = { package = "cw20-staked-balance-voting", version = "0.1.0" } +cw4-voting-v1 = { package = "cw4-voting", version = "0.1.0" } +voting-v1 = { package = "dao-voting", version = "0.1.0" } +stake-cw20-v03 = { package = "stake-cw20", version = "0.2.6" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..99e471dfd --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +Copyright 2022 DAO DAO + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..c02bd21b8 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# DAO Contracts + +[![codecov](https://codecov.io/gh/DA0-DA0/dao-contracts/branch/main/graph/badge.svg?token=SCKOIPYZPV)](https://codecov.io/gh/DA0-DA0/dao-contracts) + +This is a collection of smart contracts for building composable, modular, and upgradable DAOs. + +For a detailed look at how these contracts work, see [our wiki](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design). + +Our most recently [audited](https://github.com/oak-security/audit-reports/blob/master/DAO%20DAO/2023-02-06%20Audit%20Report%20-%20DAO%20DAO%202%20v1.0.pdf) release is `v2.0.0`. If you believe you have found a problem, please [let us know](SECURITY.md). + +## Overview + +Every DAO is made up of three modules: + +1. A voting power module, which manages the voting power of DAO members. +2. Any number of proposal modules, which manage proposals in the DAO. +3. A core module, which holds the DAO treasury. + +![image](https://user-images.githubusercontent.com/30676292/220181882-737c4dd3-a85d-498c-a1f2-067b317418a9.png) + +For example, voting power might be based on [staked governance tokens](https://github.com/DA0-DA0/dao-contracts/tree/main/contracts/voting/dao-voting-cw20-staked), [staked NFTs](https://github.com/DA0-DA0/dao-contracts/tree/main/contracts/voting/dao-voting-cw721-staked), or [membership](https://github.com/DA0-DA0/dao-contracts/tree/main/contracts/voting/dao-voting-cw4) and proposal modules might implement [yes/no](https://github.com/DA0-DA0/dao-contracts/tree/main/contracts/proposal/dao-proposal-single), [multiple-choice](https://github.com/DA0-DA0/dao-contracts/tree/main/contracts/proposal/dao-proposal-multiple), or [ranked-choice](https://github.com/DA0-DA0/dao-contracts/tree/main/contracts/proposal/dao-proposal-condorcet) voting. + +Each module type has a [standard interface](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design). As a result, any voting module can be used with any proposal module, and any proposal module with any voting module. + +The best way to get started is to create a DAO! We maintain an [open source](https://github.com/DA0-DA0/dao-dao-ui) frontend you can find at [daodao.zone](https://daodao.zone). + +## Why? + +Our institutions grew rapidly after 1970, but as time passed their priorities shifted from growth, to protectionism. We're fighting this. We believe The Internet is where the organizations of tomorrow will be built. + +DAO DAO is a global community working on Internet governance, and [a real DAO](https://daodao.zone/dao/juno10h0hc64jv006rr8qy0zhlu4jsxct8qwa0vtaleayh0ujz0zynf2s2r7v8q#proposals). We've never raised money, and all our work is open-source. We hope you'll [join us](https://discord.gg/sAaGuyW3D2). + +## Links and Resources + +- [DAO DAO DAO](https://daodao.zone/dao/juno10h0hc64jv006rr8qy0zhlu4jsxct8qwa0vtaleayh0ujz0zynf2s2r7v8q) +- [Discord](https://discord.gg/sAaGuyW3D2) +- [Docs](https://docs.daodao.zone) +- [Manually Instantiating a DAO](https://github.com/DA0-DA0/dao-contracts/wiki/Instantiating-a-DAO) +- [Twitter](https://github.com/DA0-DA0) +- [What is a DAO?](https://docs.daodao.zone/docs/introduction/what-is-dao) + +## Developers + +Information about our development workflow and how to contribute can be found in [CONTRIBUTING.md](./CONTRIBUTING.md). + +## Testing + +### Unit tests + +Run `cargo test`, or `just test` from the project root to run the unit tests. + +### Integration tests + +Run `just bootstrap-dev` to spin up a local environment and `just integration-test-dev` to run tests against it. + +See [ci/integration-tests/README.md](ci/integration-tests/README.md) for more information. + +## Disclaimer + +DAO DAO TOOLING IS PROVIDED “AS IS”, AT YOUR OWN RISK, AND WITHOUT +WARRANTIES OF ANY KIND. No developer or entity involved in creating +the DAO DAO UI or smart contracts will be liable for any claims or +damages whatsoever associated with your use, inability to use, or your +interaction with other users of DAO DAO tooling, including any direct, +indirect, incidental, special, exemplary, punitive or consequential +damages, or loss of profits, cryptocurrencies, tokens, or anything +else of value. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..87f0c9709 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +If you find a security vulnerability in our contracts it's a very big +deal and we'd like to work with you to get it fixed. To keep the DAOs +that use DAO DAO safe, we ask that you don't mention it publicly and +get in touch with one of us. + +For Discord, please DM: + +- ekez#9732 (PST, often up late so may be good for Eastern European and Asian time zone mornings) +- elsehow#3115 (PST, more avaliable than ekez in PST mornings) +- Callum#5521 (UTC) + +DM as many of us as you need to get a response. You can join our +Discord via [this link](https://discord.com/invite/sAaGuyW3D2). + +If you're concerned about Discord's phone number requirements feel +free to email a report to ekez at `ekez@withoutdoing.com`. That email +uses [gsuite](https://workspace.google.com/); if you're worried about +Google you can use this public key: + +``` +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCvCEX7GpPmUji7jR5XMDJOOSVQM2ne7nJB8OfE1xqEPBruOin1g3t9mLdQmVUkARHOW2lCup4UUeO1F/wAbfSk/CrpxHM7ErtD1FEzbOv/wBUWpsvne6dGfXw/zPQplUJ7Lk04Iln4WAgfxTuXEgJnXf/HRHovWaTWX+Eo3Br2vWQqcUc99Cy1Rvf8QTHT/UgDnqRcx67Yq1ndHAXBhNWcXGysgL4qyt79Q3rB+TR3yPugCBBMxvzEljFGCUc7P4jI+sXVFNzrghLOVwaGGfVDS66FFzEY0Hg/odfK4NdCSjGgHwU8y9oRjkgZFzhsdLp94pkLIAX143+UOribLlrOGSr/1OtJA4aIfenJZVE2vt/A4HPKtlBP6X6figOS+QWgVwGY4tzoeEH6oqCjniF5EgtB741PDzMlnpFcrkrdAeM6WqBkxeyf5tEebahsMEI3ZNkMjKVpBCJfe6Ms4yFA815MVKsBPHWWaJ4XhvhnbinENcFU3rBflOvSsjx0MaxkLb2+Ve1NiEZ3Zj82Nycf1cWtOp0n5LCjEtfCYvzfIy6BXoqAC97gAKf++13t/+3ECJCIPcoXKFWMiXfjSt/GiQwhbmAGWNX+pbpqWMd7QeqT1cQzvAdrvFnOmFe8/wymovYeEaM0Qv7w1gbPYat9eCbBN6IGqX/aJiSCokYl3Q== +``` diff --git a/ci/bootstrap-env/Cargo.toml b/ci/bootstrap-env/Cargo.toml new file mode 100644 index 000000000..5ea498f2b --- /dev/null +++ b/ci/bootstrap-env/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bootstrap-env" +version = "0.2.0" +edition = { workspace = true } +repository = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +cosm-orc = { workspace = true } +cw20 = { workspace = true } +cw-utils = { workspace = true } +cosmwasm-std = { workspace = true, features = ["ibc3"] } +cw-admin-factory = { workspace = true } +dao-dao-core = { workspace = true } +cw20-stake = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-proposal-single = { workspace = true } +dao-pre-propose-single = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } + +anyhow = { workspace = true } +env_logger = { workspace = true } +serde = { workspace = true, default-features = false, features = ["derive"] } +serde_json = { workspace = true } +serde_yaml = { workspace = true } diff --git a/ci/bootstrap-env/src/main.rs b/ci/bootstrap-env/src/main.rs new file mode 100644 index 000000000..b8bdeb0a5 --- /dev/null +++ b/ci/bootstrap-env/src/main.rs @@ -0,0 +1,187 @@ +use anyhow::Result; +use cosm_orc::orchestrator::{Coin, Key, SigningKey}; +use cosm_orc::{config::cfg::Config, orchestrator::cosm_orc::CosmOrc}; +use cosmwasm_std::{to_binary, Decimal, Empty, Uint128}; +use cw20::Cw20Coin; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_voting::{ + deposit::{DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + pre_propose::PreProposeInfo, + threshold::PercentageThreshold, + threshold::Threshold, +}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::fs; +use std::time::Duration; + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Account { + name: String, + address: String, + mnemonic: String, +} + +fn main() -> Result<()> { + env_logger::init(); + + let config = env::var("CONFIG").expect("missing yaml CONFIG env var"); + let mut cfg = Config::from_yaml(&config)?; + let mut orc = CosmOrc::new(cfg.clone(), false)?; + + // use first test user as DAO admin, and only DAO member: + let accounts: Vec = + serde_json::from_slice(&fs::read("ci/configs/test_accounts.json")?)?; + let account = accounts[0].clone(); + + let key = SigningKey { + name: account.name, + key: Key::Mnemonic(account.mnemonic), + derivation_path: cfg.chain_cfg.derivation_path.clone(), + }; + let addr = account.address; + + orc.poll_for_n_blocks(1, Duration::from_millis(20_000), true)?; + + orc.store_contracts("artifacts", &key, None)?; + + let msg = dao_interface::msg::InstantiateMsg { + admin: Some(addr.clone()), + name: "DAO DAO".to_string(), + description: "A DAO that makes DAO tooling".to_string(), + image_url: Some("https://zmedley.com/raw_logo.png".to_string()), + dao_uri: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: orc.contract_map.code_id("dao_voting_cw20_staked")?, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: orc.contract_map.code_id("cw20_base")?, + label: "DAO DAO Gov token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: addr.clone(), + amount: Uint128::new(100_000_000), + }], + marketing: None, + staking_code_id: orc.contract_map.code_id("cw20_stake")?, + unstaking_duration: Some(cw_utils::Duration::Time(1209600)), + initial_dao_balance: None, + }, + active_threshold: None, + })?, + admin: Some(Admin::CoreModule {}), + label: "DAO DAO Voting Module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: orc.contract_map.code_id("dao_proposal_single")?, + msg: to_binary(&dao_proposal_single::msg::InstantiateMsg { + min_voting_period: None, + threshold: Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: cw_utils::Duration::Time(432000), + allow_revoting: false, + only_members_execute: true, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: orc.contract_map.code_id("dao_pre_propose_single")?, + msg: to_binary(&dao_pre_propose_single::InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1000000000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO Pre-Propose Module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + })?, + admin: Some(Admin::CoreModule {}), + label: "DAO DAO Proposal Module".to_string(), + }], + initial_items: None, + }; + + // Init dao dao dao with an initial treasury of 9000000 tokens + orc.instantiate( + "dao_dao_core", + "dao_init", + &msg, + &key, + Some(addr.parse()?), + vec![Coin { + denom: cfg.chain_cfg.denom.parse()?, + amount: 9000000, + }], + )?; + + orc.instantiate( + "cw_admin_factory", + "admin_factory_init", + &cw_admin_factory::msg::InstantiateMsg {}, + &key, + None, + vec![], + )?; + + println!(" ------------------------ "); + println!("Config Variables\n"); + + println!("Admin user address: {addr}"); + + println!( + "NEXT_PUBLIC_CW20_CODE_ID={}", + orc.contract_map.code_id("cw20_base")? + ); + println!( + "NEXT_PUBLIC_CW4GROUP_CODE_ID={}", + orc.contract_map.code_id("cw4_group")? + ); + println!( + "NEXT_PUBLIC_CWCORE_CODE_ID={}", + orc.contract_map.code_id("dao_dao_core")? + ); + println!( + "NEXT_PUBLIC_CWPROPOSALSINGLE_CODE_ID={}", + orc.contract_map.code_id("dao_proposal_single")? + ); + println!( + "NEXT_PUBLIC_CW4VOTING_CODE_ID={}", + orc.contract_map.code_id("dao_voting_cw4")? + ); + println!( + "NEXT_PUBLIC_CW20STAKEDBALANCEVOTING_CODE_ID={}", + orc.contract_map.code_id("dao_voting_cw20_staked")? + ); + println!( + "NEXT_PUBLIC_STAKECW20_CODE_ID={}", + orc.contract_map.code_id("cw20_stake")? + ); + println!( + "NEXT_PUBLIC_DAO_CONTRACT_ADDRESS={}", + orc.contract_map.address("dao_dao_core")? + ); + println!( + "NEXT_PUBLIC_V1_FACTORY_CONTRACT_ADDRESS={}", + orc.contract_map.address("cw_admin_factory")? + ); + + // 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(); + fs::write( + "ci/configs/cosm-orc/local.yaml", + serde_yaml::to_string(&cfg)?, + )?; + + Ok(()) +} diff --git a/ci/configs/cosm-orc/ci.yaml b/ci/configs/cosm-orc/ci.yaml new file mode 100644 index 000000000..18e1e55fd --- /dev/null +++ b/ci/configs/cosm-orc/ci.yaml @@ -0,0 +1,8 @@ +chain_cfg: + denom: "ujunox" + prefix: "juno" + chain_id: "testing" + grpc_endpoint: "http://localhost:9090/" + derivation_path: "m/44'/118'/0'/0/0" + gas_price: 0.1 + gas_adjustment: 1.5 diff --git a/ci/configs/cosm-orc/testnet.yaml b/ci/configs/cosm-orc/testnet.yaml new file mode 100644 index 000000000..955590feb --- /dev/null +++ b/ci/configs/cosm-orc/testnet.yaml @@ -0,0 +1,14 @@ +chain_cfg: + denom: "ujunox" + prefix: "juno" + chain_id: "uni-5" + grpc_endpoint: "http://juno-testnet-grpc.polkachu.com:26090" + derivation_path: "m/44'/118'/0'/0/0" + gas_price: 0.1 + gas_adjustment: 1.5 + +contract_deploy_info: + cw20_base: + code_id: 229 + cw4_group: + code_id: 230 diff --git a/ci/configs/test_accounts.json b/ci/configs/test_accounts.json new file mode 100644 index 000000000..06733aebb --- /dev/null +++ b/ci/configs/test_accounts.json @@ -0,0 +1,27 @@ +[ + { + "name": "user1", + "address": "juno10j9gpw9t4jsz47qgnkvl5n3zlm2fz72k67rxsg", + "mnemonic": "siren window salt bullet cream letter huge satoshi fade shiver permit offer happy immense wage fitness goose usual aim hammer clap about super trend" + }, + { + "name": "user2", + "address": "juno1v9xynggs6vnrv2x5ufxdj398u2ghc5n9ya57ea", + "mnemonic": "devote vast fashion hat flat ensure earth abandon gesture erode member few common lonely sword rapid police fury another surround dragon purse swear patch" + }, + { + "name": "user3", + "address": "juno1965jgwxjp39sz2urty8r2khjdyy8dgt78kzfj6", + "mnemonic": "vessel utility occur solid post dry now blush federal bonus fiscal differ mesh puppy sock cloud diagram fence silk shield bless spring ordinary banana" + }, + { + "name": "user4", + "address": "juno1zn6psr8ngkagj3wmt0830y97s2uae6eazgnnsa", + "mnemonic": "finger attitude bargain drop nephew hunt try spring link swamp submit devote forget canvas whisper almost typical photo evoke width penalty start frost strategy" + }, + { + "name": "user5", + "address": "juno1d64uqyvwzsr6wz5aw9p8rt08jya78xpjdxxaa5", + "mnemonic": "record regular they cupboard someone mutual marble evil swamp bread expire pioneer casual pitch help road hard survey unfold false decrease material bargain youth" + } +] \ No newline at end of file diff --git a/ci/integration-tests/.cargo/config b/ci/integration-tests/.cargo/config new file mode 100644 index 000000000..13f255576 --- /dev/null +++ b/ci/integration-tests/.cargo/config @@ -0,0 +1,3 @@ +[alias] +c = "check --tests" +t = "test -- --ignored --test-threads 1" \ No newline at end of file diff --git a/ci/integration-tests/Cargo.toml b/ci/integration-tests/Cargo.toml new file mode 100644 index 000000000..3d9e217b8 --- /dev/null +++ b/ci/integration-tests/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "integration-tests" +version = "0.1.0" +edition = { workspace = true } + +# This crate depends on rand. These are not featured in +# wasm builds of cosmwasm. Despite this crate only being used as a dev +# dependency, because it is part of the workspace it will always be +# compiled. There is no good way to remove a member from a workspace +# conditionally. As such, we don't compile anything here if we're +# targeting wasm. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +cosm-orc = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +cw721-base = { workspace = true } +cw721-roles = { workspace = true } +cw721 = { workspace = true } +cw-utils = { workspace = true } +cosmwasm-std = { workspace = true, features = ["ibc3"] } + +cw-vesting = { workspace = true } +cw20-stake = { workspace = true } +dao-dao-core = { workspace = true } +dao-interface = { workspace = true } +dao-pre-propose-single = { workspace = true } +dao-proposal-single = { workspace = true } +dao-voting = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-voting-cw721-staked = { workspace = true } + +assert_matches = { workspace = true } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +once_cell = { workspace = true } +env_logger = { workspace = true } +test-context = { workspace = true } +cosm-tome = { workspace = true } +cosmos-sdk-proto = { workspace = true } +rand = { workspace = true } diff --git a/ci/integration-tests/README.md b/ci/integration-tests/README.md new file mode 100644 index 000000000..f98024aba --- /dev/null +++ b/ci/integration-tests/README.md @@ -0,0 +1,73 @@ +# Dao Dao Integration Tests + +Dao Dao e2e integration tests with gas profiling. + +`cd ci/integration_tests && cargo t` to run all tests. + +`cargo t fn_test_name` or `just integration-test-dev fn_test_name` to run individual integration tests. + +## Running Locally + +### Hitting Local Juno + +#### Run All Tests +`just integration-test` + +#### Nicest Test Dev Loop + +This will create a local dev env, and then easily test one integration test, skipping optimization + contract storage each time we call `just integration-test-dev`. + +Run once to init env: +* `just bootstrap-dev` + +Run many times while developing tests: +* `just integration-test-dev fn_test_name` + +Or Use `just integration-test-dev` to run all integration tests while skipping setting up local dev + contract optimization / storage. + +### Hitting Testnet + +* `cd ci/integration_tests` +* Change `src/helpers/chain.rs::test_account()` with your testnet account +* `CONFIG="../configs/cosm-orc/testnet.yaml" just integration-test` + + +## Adding New Integration Tests + +Add new tests in `src/tests`: +```rust +#[test_context(Chain)] +#[test] +#[ignore] +fn new_dao_has_no_items(chain: &mut Chain) { + let res = create_dao( + chain, + None, + "ex_create_dao", + chain.users["user1"].account.address.clone(), + ); + let dao = res.unwrap(); + + // use the native rust types to interact with the contract + let res = chain + .orc + .query( + "cw_core", + &dao_interface::msg::QueryMsg::GetItem { + key: "meme".to_string(), + }, + ) + .unwrap(); + let res: GetItemResponse = res.data().unwrap(); + + assert_eq!(res.item, None); +} +``` + +We are currently +[ignoring](https://doc.rust-lang.org/book/ch11-02-running-tests.html#ignoring-some-tests-unless-specifically-requested) +all integration tests by adding the `#[ignore]` annotation to them, +because we want to skip them when people run `cargo test` from the +workspace root. + +Run `cargo c` to compile the tests. diff --git a/ci/integration-tests/src/helpers/chain.rs b/ci/integration-tests/src/helpers/chain.rs new file mode 100644 index 000000000..20b3d27d4 --- /dev/null +++ b/ci/integration-tests/src/helpers/chain.rs @@ -0,0 +1,126 @@ +use cosm_orc::orchestrator::{CosmosgRPC, Key, SigningKey}; +use cosm_orc::{config::cfg::Config, orchestrator::cosm_orc::CosmOrc}; +use once_cell::sync::OnceCell; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::Path; +use std::time::Duration; +use test_context::TestContext; + +static CONFIG: OnceCell = OnceCell::new(); + +#[derive(Debug)] +pub struct Cfg { + cfg: Config, + gas_report_dir: String, +} + +pub struct Chain { + pub cfg: Config, + pub orc: CosmOrc, + pub users: HashMap, +} + +#[derive(Clone, Debug)] +pub struct SigningAccount { + pub account: Account, + pub key: SigningKey, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Account { + pub name: String, + pub address: String, + pub mnemonic: String, +} + +// NOTE: we have to run the integration tests in one thread right now. +// We get `account sequence mismatch` CosmosSDK error when we run in parallel. +// We could either serialize the `account.sequence` per key, or use a different key per test. + +impl TestContext for Chain { + fn setup() -> Self { + let cfg = CONFIG.get_or_init(global_setup).cfg.clone(); + let orc = CosmOrc::new(cfg.clone(), true).unwrap(); + let users = test_accounts(cfg.chain_cfg.derivation_path.clone()); + Self { cfg, orc, users } + } + + fn teardown(self) { + let cfg = CONFIG.get().unwrap(); + save_gas_report(&self.orc, &cfg.gas_report_dir); + } +} + +fn test_accounts(derivation_path: String) -> HashMap { + let bytes = fs::read("../configs/test_accounts.json").unwrap(); + let accounts: Vec = serde_json::from_slice(&bytes).unwrap(); + + let mut account_map = HashMap::new(); + for account in accounts { + account_map.insert( + account.name.clone(), + SigningAccount { + account: account.clone(), + key: SigningKey { + name: account.name, + key: Key::Mnemonic(account.mnemonic), + derivation_path: derivation_path.clone(), + }, + }, + ); + } + account_map +} + +// global_setup() runs once before all of the tests +fn global_setup() -> Cfg { + env_logger::init(); + let config = env::var("CONFIG").expect("missing yaml CONFIG env var"); + let gas_report_dir = env::var("GAS_OUT_DIR").unwrap_or_else(|_| "gas_reports".to_string()); + + let mut cfg = Config::from_yaml(&config).unwrap(); + let mut orc = CosmOrc::new(cfg.clone(), true).unwrap(); + + let accounts = test_accounts(cfg.chain_cfg.derivation_path.clone()); + + // Poll for first block to make sure the node is up: + orc.poll_for_n_blocks(1, Duration::from_millis(20_000), true) + .unwrap(); + + let skip_storage = env::var("SKIP_CONTRACT_STORE").unwrap_or_else(|_| "false".to_string()); + if !skip_storage.parse::().unwrap() { + let contract_dir = "../../artifacts"; + orc.store_contracts(contract_dir, &accounts["user1"].key, None) + .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 { + cfg, + gas_report_dir, + } +} + +fn save_gas_report(orc: &CosmOrc, gas_report_dir: &str) { + let report = orc + .gas_profiler_report() + .expect("error fetching profile reports"); + + let j: Value = serde_json::to_value(report).unwrap(); + + let p = Path::new(gas_report_dir); + if !p.exists() { + fs::create_dir(p).unwrap(); + } + + let mut rng = rand::thread_rng(); + let file_name = format!("test-{}.json", rng.gen::()); + fs::write(p.join(file_name), j.to_string()).unwrap(); +} diff --git a/ci/integration-tests/src/helpers/helper.rs b/ci/integration-tests/src/helpers/helper.rs new file mode 100644 index 000000000..3c685c22d --- /dev/null +++ b/ci/integration-tests/src/helpers/helper.rs @@ -0,0 +1,293 @@ +use super::chain::Chain; +use anyhow::Result; +use cosm_orc::orchestrator::SigningKey; +use cosmwasm_std::{to_binary, CosmosMsg, Decimal, Empty, Uint128}; +use cw20::Cw20Coin; +use cw_utils::Duration; +use dao_interface::query::DumpStateResponse; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_voting::{ + deposit::{DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + threshold::PercentageThreshold, + threshold::Threshold, + voting::Vote, +}; + +pub const DEPOSIT_AMOUNT: Uint128 = Uint128::new(1_000_000); + +#[derive(Debug)] +pub struct DaoState { + pub addr: String, + pub state: DumpStateResponse, +} + +pub fn create_dao( + chain: &mut Chain, + admin: Option, + op_name: &str, + user_addr: String, + key: &SigningKey, +) -> Result { + let msg = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin, + name: "DAO DAO".to_string(), + description: "A DAO that makes DAO tooling".to_string(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: chain.orc.contract_map.code_id("dao_voting_cw20_staked")?, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: chain.orc.contract_map.code_id("cw20_base")?, + label: "DAO DAO Gov token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: user_addr, + amount: Uint128::new(100_000_000), + }], + marketing: None, + staking_code_id: chain.orc.contract_map.code_id("cw20_stake")?, + unstaking_duration: Some(Duration::Time(1209600)), + initial_dao_balance: None, + }, + active_threshold: None, + })?, + admin: Some(Admin::CoreModule {}), + label: "DAO DAO Voting Module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: chain.orc.contract_map.code_id("dao_proposal_single")?, + msg: to_binary(&dao_proposal_single::msg::InstantiateMsg { + min_voting_period: None, + threshold: Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(35)), + }, + max_voting_period: Duration::Time(432000), + allow_revoting: false, + only_members_execute: true, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: chain.orc.contract_map.code_id("dao_pre_propose_single")?, + msg: to_binary(&dao_pre_propose_single::InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: DEPOSIT_AMOUNT, + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO Pre-Propose Module".to_string(), + }, + }, + })?, + admin: Some(Admin::CoreModule {}), + label: "DAO DAO Proposal Module".to_string(), + }], + initial_items: None, + }; + + chain + .orc + .instantiate("dao_dao_core", op_name, &msg, key, None, vec![])?; + + // add proposal, pre-propose, voting, cw20_stake, and cw20_base + // contracts to the orc contract map. + + let state: DumpStateResponse = chain + .orc + .query("dao_dao_core", &dao_interface::msg::QueryMsg::DumpState {})? + .data() + .unwrap(); + chain + .orc + .contract_map + .add_address( + "dao_proposal_single", + state.proposal_modules[0].address.to_string(), + ) + .unwrap(); + + let ProposalCreationPolicy::Module { addr: pre_propose } = chain + .orc + .query( + "dao_proposal_single", + &dao_proposal_single::msg::QueryMsg::ProposalCreationPolicy {} + ).unwrap() + .data() + .unwrap() + else { + panic!("expected pre-propose module") + }; + chain + .orc + .contract_map + .add_address("dao_pre_propose_single", pre_propose) + .unwrap(); + + chain + .orc + .contract_map + .add_address("dao_voting_cw20_staked", state.voting_module.to_string()) + .unwrap(); + let cw20_stake: String = chain + .orc + .query( + "dao_voting_cw20_staked", + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap() + .data() + .unwrap(); + chain + .orc + .contract_map + .add_address("cw20_stake", cw20_stake) + .unwrap(); + let cw20_base: String = chain + .orc + .query( + "dao_voting_cw20_staked", + &dao_voting_cw20_staked::msg::QueryMsg::TokenContract {}, + ) + .unwrap() + .data() + .unwrap(); + chain + .orc + .contract_map + .add_address("cw20_base", cw20_base) + .unwrap(); + + Ok(DaoState { + addr: chain.orc.contract_map.address("dao_dao_core")?, + state, + }) +} + +pub fn stake_tokens(chain: &mut Chain, how_many: u128, key: &SigningKey) { + chain + .orc + .execute( + "cw20_base", + "send_and_stake_cw20", + &cw20::Cw20ExecuteMsg::Send { + contract: chain.orc.contract_map.address("cw20_stake").unwrap(), + amount: Uint128::new(how_many), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + key, + vec![], + ) + .unwrap(); + chain + .orc + .poll_for_n_blocks(1, std::time::Duration::from_millis(20_000), false) + .unwrap(); +} + +pub fn create_proposal( + chain: &mut Chain, + msgs: Vec, + key: &SigningKey, +) -> Result { + let next_id: u64 = chain + .orc + .query( + "dao_proposal_single", + &dao_proposal_single::msg::QueryMsg::NextProposalId {}, + ) + .unwrap() + .data() + .unwrap(); + + // increase allowance to pay proposal deposit. + chain + .orc + .execute( + "cw20_base", + "cw20_base_increase_allowance", + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: chain + .orc + .contract_map + .address("dao_pre_propose_single") + .unwrap(), + amount: DEPOSIT_AMOUNT, + expires: None, + }, + key, + vec![], + ) + .unwrap(); + + chain + .orc + .execute( + "dao_pre_propose_single", + "pre_propose_propose", + &dao_pre_propose_single::ExecuteMsg::Propose { + msg: dao_pre_propose_single::ProposeMessage::Propose { + title: "title".to_string(), + description: "desc".to_string(), + msgs, + }, + }, + key, + vec![], + ) + .unwrap(); + + let r = chain + .orc + .query( + "dao_proposal_single", + &dao_proposal_single::msg::QueryMsg::Proposal { + proposal_id: next_id, + }, + ) + .unwrap() + .data() + .unwrap(); + + Ok(r) +} + +pub fn vote(chain: &mut Chain, proposal_id: u64, vote: Vote, key: &SigningKey) { + chain + .orc + .execute( + "dao_proposal_single", + "dao_proposal_single_vote", + &dao_proposal_single::msg::ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + key, + vec![], + ) + .unwrap(); +} + +pub fn execute(chain: &mut Chain, proposal_id: u64, key: &SigningKey) { + chain + .orc + .execute( + "dao_proposal_single", + "dao_proposal_single_vote", + &dao_proposal_single::msg::ExecuteMsg::Execute { proposal_id }, + key, + vec![], + ) + .unwrap(); +} diff --git a/ci/integration-tests/src/helpers/mod.rs b/ci/integration-tests/src/helpers/mod.rs new file mode 100644 index 000000000..5cc5aff66 --- /dev/null +++ b/ci/integration-tests/src/helpers/mod.rs @@ -0,0 +1,3 @@ +pub mod helper; + +pub mod chain; diff --git a/ci/integration-tests/src/lib.rs b/ci/integration-tests/src/lib.rs new file mode 100644 index 000000000..3d559812d --- /dev/null +++ b/ci/integration-tests/src/lib.rs @@ -0,0 +1,7 @@ +#![allow(dead_code)] + +#[cfg(not(target_arch = "wasm32"))] +mod tests; + +#[cfg(not(target_arch = "wasm32"))] +mod helpers; diff --git a/ci/integration-tests/src/tests/cw20_stake_test.rs b/ci/integration-tests/src/tests/cw20_stake_test.rs new file mode 100644 index 000000000..13cb0d226 --- /dev/null +++ b/ci/integration-tests/src/tests/cw20_stake_test.rs @@ -0,0 +1,120 @@ +use crate::helpers::{chain::Chain, helper::create_dao}; +use cosmwasm_std::{to_binary, Uint128}; +use cw20_stake::{msg::StakedValueResponse, state::Config}; +use dao_interface::voting::VotingPowerAtHeightResponse; +use std::time::Duration; +use test_context::test_context; + +// #### ExecuteMsg ##### + +#[test_context(Chain)] +#[test] +#[ignore] +fn execute_stake_tokens(chain: &mut Chain) { + let user_addr = chain.users["user1"].account.address.clone(); + let user_key = chain.users["user1"].key.clone(); + let voting_contract = "dao_voting_cw20_staked"; + + let res = create_dao( + chain, + None, + "exc_stake_create_dao", + user_addr.clone(), + &user_key, + ); + let dao = res.unwrap(); + + let voting_addr = dao.state.voting_module.as_str(); + + // stake dao tokens: + chain + .orc + .contract_map + .add_address(voting_contract, voting_addr) + .unwrap(); + let staking_addr: String = chain + .orc + .query( + voting_contract, + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap() + .data() + .unwrap(); + + chain + .orc + .contract_map + .add_address("cw20_stake", staking_addr.to_string()) + .unwrap(); + let res = chain + .orc + .query( + "cw20_stake", + &cw20_stake::msg::QueryMsg::StakedValue { + address: user_addr.clone(), + }, + ) + .unwrap(); + let staked_value: StakedValueResponse = res.data().unwrap(); + + assert_eq!(staked_value.value, Uint128::new(0)); + + let res = chain + .orc + .query("cw20_stake", &cw20_stake::msg::QueryMsg::GetConfig {}) + .unwrap(); + let config: Config = res.data().unwrap(); + + chain + .orc + .contract_map + .add_address("cw20_base", config.token_address.as_str()) + .unwrap(); + chain + .orc + .execute( + "cw20_base", + "exc_stake_stake_tokens", + &cw20_base::msg::ExecuteMsg::Send { + contract: staking_addr, + amount: Uint128::new(100), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &user_key, + vec![], + ) + .unwrap(); + + let res = chain + .orc + .query( + "cw20_stake", + &cw20_stake::msg::QueryMsg::StakedValue { + address: user_addr.clone(), + }, + ) + .unwrap(); + let staked_value: StakedValueResponse = res.data().unwrap(); + + assert_eq!(staked_value.value, Uint128::new(100)); + + chain + .orc + .poll_for_n_blocks(1, Duration::from_millis(20_000), false) + .unwrap(); + + let res = chain + .orc + .query( + "dao_dao_core", + &dao_interface::msg::QueryMsg::VotingPowerAtHeight { + address: user_addr, + height: None, + }, + ) + .unwrap(); + let power: VotingPowerAtHeightResponse = res.data().unwrap(); + + assert_eq!(power.power, Uint128::new(100)); +} diff --git a/ci/integration-tests/src/tests/cw_core_test.rs b/ci/integration-tests/src/tests/cw_core_test.rs new file mode 100644 index 000000000..348ac3925 --- /dev/null +++ b/ci/integration-tests/src/tests/cw_core_test.rs @@ -0,0 +1,382 @@ +use crate::helpers::chain::Chain; +use crate::helpers::helper::create_dao; +use assert_matches::assert_matches; +use cosm_orc::orchestrator::error::CosmwasmError::TxError; +use cosm_orc::orchestrator::error::ProcessError; +use cosmwasm_std::{to_binary, Addr, CosmosMsg, Decimal, Uint128}; +use cw20_stake::msg::{StakedValueResponse, TotalValueResponse}; + +use cw_utils::Duration; +use dao_interface::query::{GetItemResponse, PauseInfoResponse}; +use dao_voting::{ + pre_propose::ProposalCreationPolicy, threshold::PercentageThreshold, threshold::Threshold, +}; +use test_context::test_context; + +// #### ExecuteMsg ##### + +// TODO: Add tests for all cw-core execute msgs + +#[test_context(Chain)] +#[test] +#[ignore] +fn execute_execute_admin_msgs(chain: &mut Chain) { + let user_addr = chain.users["user1"].account.address.clone(); + let user_key = chain.users["user1"].key.clone(); + + // if you are not the admin, you cant execute admin msgs: + let res = create_dao( + chain, + None, + "exc_admin_msgs_create_dao", + user_addr.clone(), + &user_key, + ); + let dao = res.unwrap(); + + let res = chain.orc.execute( + "dao_dao_core", + "exc_admin_msgs_pause_dao_fail", + &dao_interface::msg::ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![CosmosMsg::Wasm(cosmwasm_std::WasmMsg::Execute { + contract_addr: dao.addr, + msg: to_binary(&dao_interface::msg::ExecuteMsg::Pause { + duration: Duration::Time(100), + }) + .unwrap(), + funds: vec![], + })], + }, + &user_key, + vec![], + ); + + assert_matches!(res.unwrap_err(), ProcessError::CosmwasmError(TxError(..))); + + let res = chain + .orc + .query("dao_dao_core", &dao_interface::msg::QueryMsg::PauseInfo {}) + .unwrap(); + let res: PauseInfoResponse = res.data().unwrap(); + + assert_eq!(res, PauseInfoResponse::Unpaused {}); + + // if you are the admin you can execute admin msgs: + let res = create_dao( + chain, + Some(user_addr.clone()), + "exc_admin_msgs_create_dao_with_admin", + user_addr, + &user_key, + ); + let dao = res.unwrap(); + + chain + .orc + .execute( + "dao_dao_core", + "exc_admin_msgs_pause_dao", + &dao_interface::msg::ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![CosmosMsg::Wasm(cosmwasm_std::WasmMsg::Execute { + contract_addr: dao.addr, + msg: to_binary(&dao_interface::msg::ExecuteMsg::Pause { + duration: Duration::Height(100), + }) + .unwrap(), + funds: vec![], + })], + }, + &user_key, + vec![], + ) + .unwrap(); + + let res = chain + .orc + .query("dao_dao_core", &dao_interface::msg::QueryMsg::PauseInfo {}) + .unwrap(); + + let res: PauseInfoResponse = res.data().unwrap(); + assert_ne!(res, PauseInfoResponse::Unpaused {}); +} + +#[test_context(Chain)] +#[test] +#[ignore] +fn execute_items(chain: &mut Chain) { + let user_addr = chain.users["user1"].account.address.clone(); + let user_key = chain.users["user1"].key.clone(); + + // add item: + let res = create_dao( + chain, + Some(user_addr.clone()), + "exc_items_create_dao", + user_addr, + &user_key, + ); + + let dao = res.unwrap(); + + let res = chain + .orc + .query( + "dao_dao_core", + &dao_interface::msg::QueryMsg::GetItem { + key: "meme".to_string(), + }, + ) + .unwrap(); + let res: GetItemResponse = res.data().unwrap(); + + assert_eq!(res.item, None); + + chain + .orc + .execute( + "dao_dao_core", + "exc_items_set", + &dao_interface::msg::ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![CosmosMsg::Wasm(cosmwasm_std::WasmMsg::Execute { + contract_addr: dao.addr.clone(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::SetItem { + key: "meme".to_string(), + value: "foobar".to_string(), + }) + .unwrap(), + funds: vec![], + })], + }, + &user_key, + vec![], + ) + .unwrap(); + + let res = chain + .orc + .query( + "dao_dao_core", + &dao_interface::msg::QueryMsg::GetItem { + key: "meme".to_string(), + }, + ) + .unwrap(); + let res: GetItemResponse = res.data().unwrap(); + + assert_eq!(res.item, Some("foobar".to_string())); + + // remove item: + chain + .orc + .execute( + "dao_dao_core", + "exc_items_rm", + &dao_interface::msg::ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![CosmosMsg::Wasm(cosmwasm_std::WasmMsg::Execute { + contract_addr: dao.addr, + msg: to_binary(&dao_interface::msg::ExecuteMsg::RemoveItem { + key: "meme".to_string(), + }) + .unwrap(), + funds: vec![], + })], + }, + &user_key, + vec![], + ) + .unwrap(); + + let res = chain + .orc + .query( + "dao_dao_core", + &dao_interface::msg::QueryMsg::GetItem { + key: "meme".to_string(), + }, + ) + .unwrap(); + let res: GetItemResponse = res.data().unwrap(); + + assert_eq!(res.item, None); +} + +// #### InstantiateMsg ##### + +#[test_context(Chain)] +#[test] +#[ignore] +fn instantiate_with_no_admin(chain: &mut Chain) { + let user_addr = chain.users["user1"].account.address.clone(); + let user_key = chain.users["user1"].key.clone(); + + let res = create_dao(chain, None, "inst_dao_no_admin", user_addr, &user_key); + let dao = res.unwrap(); + + // ensure the dao is the admin: + assert_eq!(dao.state.admin, dao.addr); + assert_eq!(dao.state.pause_info, PauseInfoResponse::Unpaused {}); + assert_eq!( + dao.state.config, + dao_interface::state::Config { + dao_uri: None, + name: "DAO DAO".to_string(), + description: "A DAO that makes DAO tooling".to_string(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false + } + ); +} + +#[test_context(Chain)] +#[test] +#[ignore] +fn instantiate_with_admin(chain: &mut Chain) { + let user_addr = chain.users["user1"].account.address.clone(); + let user_key = chain.users["user1"].key.clone(); + let voting_contract = "dao_voting_cw20_staked"; + let proposal_contract = "cw_proposal_single"; + + let res = create_dao( + chain, + Some(user_addr.clone()), + "inst_admin_create_dao", + user_addr.clone(), + &user_key, + ); + let dao = res.unwrap(); + + // general dao info is valid: + assert_eq!(dao.state.admin, user_addr); + assert_eq!(dao.state.pause_info, PauseInfoResponse::Unpaused {}); + assert_eq!( + dao.state.config, + dao_interface::state::Config { + dao_uri: None, + name: "DAO DAO".to_string(), + description: "A DAO that makes DAO tooling".to_string(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false + } + ); + + let voting_addr = dao.state.voting_module.as_str(); + let prop_addr = dao.state.proposal_modules[0].address.as_str(); + + // voting module config is valid: + chain + .orc + .contract_map + .add_address(voting_contract, voting_addr) + .unwrap(); + let res = &chain + .orc + .query( + voting_contract, + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let staking_addr: &str = res.data().unwrap(); + + chain + .orc + .contract_map + .add_address("cw20_stake", staking_addr) + .unwrap(); + let res = chain + .orc + .query( + "cw20_stake", + &cw20_stake::msg::QueryMsg::StakedValue { address: user_addr }, + ) + .unwrap(); + let staked_res: StakedValueResponse = res.data().unwrap(); + assert_eq!(staked_res.value, Uint128::new(0)); + + let res = chain + .orc + .query("cw20_stake", &cw20_stake::msg::QueryMsg::GetConfig {}) + .unwrap(); + let config_res: cw20_stake::state::Config = res.data().unwrap(); + assert_eq!(config_res.unstaking_duration, Some(Duration::Time(1209600))); + + let res = chain + .orc + .query("cw20_stake", &cw20_stake::msg::QueryMsg::Ownership {}) + .unwrap(); + let ownership: cw20_stake::msg::Ownership = res.data().unwrap(); + assert_eq!( + ownership, + cw20_stake::msg::Ownership:: { + owner: Some(Addr::unchecked( + chain.orc.contract_map.address("dao_dao_core").unwrap() + )), + pending_owner: None, + pending_expiry: None + } + ); + + let res = &chain + .orc + .query( + voting_contract, + &dao_voting_cw20_staked::msg::QueryMsg::TokenContract {}, + ) + .unwrap(); + let token_addr: &str = res.data().unwrap(); + assert_eq!(config_res.token_address, token_addr); + + assert_eq!(config_res.unstaking_duration, Some(Duration::Time(1209600))); + + let res = chain + .orc + .query("cw20_stake", &cw20_stake::msg::QueryMsg::TotalValue {}) + .unwrap(); + let total_res: TotalValueResponse = res.data().unwrap(); + assert_eq!(total_res.total, Uint128::new(0)); + + // proposal module config is valid: + chain + .orc + .contract_map + .add_address(proposal_contract, prop_addr) + .unwrap(); + let res = chain + .orc + .query( + proposal_contract, + &dao_proposal_single::msg::QueryMsg::Config {}, + ) + .unwrap(); + let config_res: dao_proposal_single::state::Config = res.data().unwrap(); + let proposal_creation_policy: dao_voting::pre_propose::ProposalCreationPolicy = chain + .orc + .query( + proposal_contract, + &dao_proposal_single::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap() + .data() + .unwrap(); + + assert_eq!(config_res.min_voting_period, None); + assert_eq!(config_res.max_voting_period, Duration::Time(432000)); + assert!(!config_res.allow_revoting); + assert!(config_res.only_members_execute); + assert!(matches!( + proposal_creation_policy, + ProposalCreationPolicy::Module { .. } + )); + assert_eq!( + config_res.threshold, + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(35)), + } + ); + assert_eq!( + config_res.dao, + chain.orc.contract_map.address("dao_dao_core").unwrap() + ); +} diff --git a/ci/integration-tests/src/tests/cw_vesting_test.rs b/ci/integration-tests/src/tests/cw_vesting_test.rs new file mode 100644 index 000000000..7d2d74292 --- /dev/null +++ b/ci/integration-tests/src/tests/cw_vesting_test.rs @@ -0,0 +1,153 @@ +use std::{str::FromStr, time::Duration}; + +use cosm_orc::orchestrator::{cosm_orc::tokio_block, Address, Coin, Denom}; +use cosm_tome::clients::client::{CosmTome, CosmosClient}; +use cosmos_sdk_proto::cosmos::staking::v1beta1::{QueryValidatorsRequest, QueryValidatorsResponse}; +use cw_vesting::{ + msg::{ExecuteMsg, InstantiateMsg}, + vesting::Schedule, +}; + +use cosmwasm_std::Uint128; +use test_context::test_context; + +use crate::helpers::chain::Chain; + +const CONTRACT_NAME: &str = "cw_vesting"; + +async fn balance(addr: &str, client: &CosmTome) -> u128 { + client + .bank_query_balance( + Address::from_str(addr).unwrap(), + Denom::from_str("ujunox").unwrap(), + ) + .await + .unwrap() + .balance + .amount +} + +// TODO CHECK INVALID ADDRESS (UN)DELEGATION ERRORS + +/// Tests that tokens can be staked and rewards accumulated. +#[test_context(Chain)] +#[test] +#[ignore] +fn test_cw_vesting_staking(chain: &mut Chain) { + let user_addr = chain.users["user3"].account.address.clone(); + let user_key = chain.users["user3"].key.clone(); + // key used for withdrawing delegator rewards so that we can + // measure the rewards w/o txn cost included. + let withdraw_key = chain.users["user5"].key.clone(); + + let req = QueryValidatorsRequest { + status: "BOND_STATUS_BONDED".to_string(), + pagination: None, + }; + + let grpc_endpoint = chain.cfg.chain_cfg.grpc_endpoint.clone().unwrap(); + let client = cosm_tome::clients::cosmos_grpc::CosmosgRPC::new(grpc_endpoint); + let vals = tokio_block( + client.query::<_, QueryValidatorsResponse>(req, "cosmos.staking.v1beta1.Query/Validators"), + ) + .unwrap(); + let validator = vals.validators.into_iter().next().unwrap().operator_address; + eprintln!("delegating to: {validator}"); + + chain + .orc + .instantiate( + CONTRACT_NAME, + "instantiate", + &InstantiateMsg { + owner: Some(user_addr.clone()), + recipient: user_addr.to_string(), + + title: "title".to_string(), + description: Some("description".to_string()), + + total: Uint128::new(100_000_000), + denom: cw_vesting::UncheckedDenom::Native("ujunox".to_string()), + + schedule: Schedule::SaturatingLinear, + start_time: None, + vesting_duration_seconds: 10, + unbonding_duration_seconds: 2 & 592000, + }, + &user_key, + None, + vec![Coin { + denom: Denom::from_str("ujunox").unwrap(), + amount: 100_000_000, + }], + ) + .unwrap(); + + // May not delegate to an invalid validator address. + chain + .orc + .execute( + CONTRACT_NAME, + "delegate_and_error", + &ExecuteMsg::Delegate { + validator: "wowsorandom".to_string(), + amount: Uint128::new(100_000_000), + }, + &user_key, + vec![], + ) + .unwrap_err(); + + chain + .orc + .execute( + CONTRACT_NAME, + "delegate", + &ExecuteMsg::Delegate { + validator: validator.clone(), + amount: Uint128::new(100_000_000), + }, + &user_key, + vec![], + ) + .unwrap(); + + chain + .orc + .poll_for_n_blocks(3, Duration::from_secs(40), false) + .unwrap(); + + let start = tokio_block(balance(&user_addr, &chain.orc.client)); + + chain + .orc + .execute( + CONTRACT_NAME, + "withdraw_reward", + &ExecuteMsg::WithdrawDelegatorReward { + validator: validator.clone(), + }, + &withdraw_key, + vec![], + ) + .unwrap(); + + let end = tokio_block(balance(&user_addr, &chain.orc.client)); + + assert!(end > start, "{end} > {start}"); + + // undelegate to complete the flow. + chain + .orc + .execute( + CONTRACT_NAME, + "undelegate", + &ExecuteMsg::Undelegate { + validator, + amount: Uint128::new(100_000_000), + }, + &user_key, + vec![], + ) + .unwrap(); +} diff --git a/ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs b/ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs new file mode 100644 index 000000000..e08d81fa9 --- /dev/null +++ b/ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs @@ -0,0 +1,275 @@ +use cosm_orc::orchestrator::{ExecReq, SigningKey}; +use cosmwasm_std::{Binary, Empty, Uint128}; +use cw_utils::Duration; +use dao_interface::state::Admin; +use test_context::test_context; + +use dao_voting_cw721_staked as module; + +use crate::helpers::chain::Chain; + +const CONTRACT_NAME: &str = "dao_voting_cw721_staked"; +const CW721_NAME: &str = "cw721_base"; + +struct CommonTest { + module: String, + cw721: String, +} + +pub fn instantiate_cw721_base(chain: &mut Chain, key: &SigningKey, minter: &str) -> String { + chain + .orc + .instantiate( + CW721_NAME, + "instantiate_cw721_base", + &cw721_base::InstantiateMsg { + name: "bad kids".to_string(), + symbol: "bad kids".to_string(), + minter: minter.to_string(), + }, + key, + None, + vec![], + ) + .unwrap() + .address + .into() +} + +fn setup_test( + chain: &mut Chain, + owner: Option, + unstaking_duration: Option, + key: &SigningKey, + minter: &str, +) -> CommonTest { + let cw721 = instantiate_cw721_base(chain, key, minter); + let module = chain + .orc + .instantiate( + CONTRACT_NAME, + "instantiate_dao_voting_cw721_staked", + &module::msg::InstantiateMsg { + owner, + nft_contract: module::msg::NftContract::Existing { + address: cw721.clone(), + }, + unstaking_duration, + active_threshold: None, + }, + key, + None, + vec![], + ) + .unwrap() + .address + .into(); + CommonTest { module, cw721 } +} + +pub fn send_nft( + chain: &mut Chain, + sender: &SigningKey, + receiver: &str, + token_id: &str, + msg: Binary, +) { + chain + .orc + .execute( + CW721_NAME, + "stake_nft", + &cw721::Cw721ExecuteMsg::SendNft { + contract: receiver.to_string(), + token_id: token_id.to_string(), + msg, + }, + sender, + vec![], + ) + .unwrap(); +} + +pub fn mint_nft(chain: &mut Chain, sender: &SigningKey, receiver: &str, token_id: &str) { + chain + .orc + .execute( + CW721_NAME, + "mint_nft", + &cw721_base::ExecuteMsg::Mint:: { + token_id: token_id.to_string(), + owner: receiver.to_string(), + token_uri: None, + extension: Empty::default(), + }, + sender, + vec![], + ) + .unwrap(); +} + +pub fn unstake_nfts(chain: &mut Chain, sender: &SigningKey, token_ids: &[&str]) { + chain + .orc + .execute( + CONTRACT_NAME, + "unstake_nfts", + &module::msg::ExecuteMsg::Unstake { + token_ids: token_ids.iter().map(|s| s.to_string()).collect(), + }, + sender, + vec![], + ) + .unwrap(); +} + +pub fn claim_nfts(chain: &mut Chain, sender: &SigningKey) { + chain + .orc + .execute( + CONTRACT_NAME, + "claim_nfts", + &module::msg::ExecuteMsg::ClaimNfts {}, + sender, + vec![], + ) + .unwrap(); +} + +pub fn query_voting_power(chain: &Chain, addr: &str, height: Option) -> Uint128 { + let res = chain + .orc + .query( + CONTRACT_NAME, + &dao_interface::voting::Query::VotingPowerAtHeight { + address: addr.to_string(), + height, + }, + ) + .unwrap(); + let data: dao_interface::voting::VotingPowerAtHeightResponse = res.data().unwrap(); + data.power +} + +pub fn mint_and_stake_nft( + chain: &mut Chain, + sender_key: &SigningKey, + sender: &str, + module: &str, + token_id: &str, +) { + mint_nft(chain, sender_key, sender, token_id); + send_nft(chain, sender_key, module, token_id, Binary::default()); +} + +#[test_context(Chain)] +#[test] +#[ignore] +fn cw721_stake_tokens(chain: &mut Chain) { + let user_addr = chain.users["user1"].account.address.clone(); + let user_key = chain.users["user1"].key.clone(); + + let CommonTest { module, .. } = setup_test(chain, None, None, &user_key, &user_addr); + + mint_and_stake_nft(chain, &user_key, &user_addr, &module, "a"); + + // Wait for voting power to be updated. + chain + .orc + .poll_for_n_blocks(1, core::time::Duration::from_millis(20_000), false) + .unwrap(); + + let voting_power = query_voting_power(chain, &user_addr, None); + assert_eq!(voting_power, Uint128::new(1)); + + unstake_nfts(chain, &user_key, &["a"]); + + chain + .orc + .poll_for_n_blocks(1, core::time::Duration::from_millis(20_000), false) + .unwrap(); + + let voting_power = query_voting_power(chain, &user_addr, None); + assert_eq!(voting_power, Uint128::zero()); +} + +#[test_context(Chain)] +#[test] +#[ignore] +fn cw721_stake_max_claims_works(chain: &mut Chain) { + use module::state::MAX_CLAIMS; + + let user_addr = chain.users["user1"].account.address.clone(); + let user_key = chain.users["user1"].key.clone(); + + let CommonTest { module, .. } = setup_test( + chain, + None, + Some(Duration::Height(1)), + &user_key, + &user_addr, + ); + + // Create `MAX_CLAIMS` claims. + + // batch_size * 3 = the number of msgs to be batched per tx. + // We cant batch all of the msgs under a single tx because we hit MAX_BLOCK_GAS limits. + let batch_size = 10; + let mut total_msgs = 0; + + let mut reqs = vec![]; + for i in 0..MAX_CLAIMS { + let token_id = i.to_string(); + + reqs.push(ExecReq { + contract_name: CW721_NAME.to_string(), + msg: Box::new(cw721_base::ExecuteMsg::Mint:: { + token_id: token_id.clone(), + owner: user_addr.to_string(), + token_uri: None, + extension: Empty::default(), + }), + funds: vec![], + }); + + reqs.push(ExecReq { + contract_name: CW721_NAME.to_string(), + msg: Box::new(cw721::Cw721ExecuteMsg::SendNft { + contract: module.to_string(), + token_id: token_id.clone(), + msg: Binary::default(), + }), + funds: vec![], + }); + + reqs.push(ExecReq { + contract_name: CONTRACT_NAME.to_string(), + msg: Box::new(module::msg::ExecuteMsg::Unstake { + token_ids: vec![token_id], + }), + funds: vec![], + }); + + if (i != 0 && i % batch_size == 0) || i == MAX_CLAIMS - 1 { + total_msgs += reqs.len(); + + chain + .orc + .execute_batch("batch_cw721_stake_max_claims", reqs, &user_key) + .unwrap(); + + reqs = vec![]; + } + } + + assert_eq!(total_msgs as u64, MAX_CLAIMS * 3); + + chain + .orc + .poll_for_n_blocks(1, core::time::Duration::from_millis(20_000), false) + .unwrap(); + + // If this works, we're golden. Other tests make sure that the + // NFTs get returned as a result of this. + claim_nfts(chain, &user_key); +} diff --git a/ci/integration-tests/src/tests/mod.rs b/ci/integration-tests/src/tests/mod.rs new file mode 100644 index 000000000..9a7ac472c --- /dev/null +++ b/ci/integration-tests/src/tests/mod.rs @@ -0,0 +1,9 @@ +pub mod cw_core_test; + +pub mod cw20_stake_test; + +pub mod dao_voting_cw721_staked_test; + +pub mod proposal_gas_test; + +pub mod cw_vesting_test; diff --git a/ci/integration-tests/src/tests/proposal_gas_test.rs b/ci/integration-tests/src/tests/proposal_gas_test.rs new file mode 100644 index 000000000..e6fa015a9 --- /dev/null +++ b/ci/integration-tests/src/tests/proposal_gas_test.rs @@ -0,0 +1,65 @@ +use cosmwasm_std::{to_binary, CosmosMsg, Empty, WasmMsg}; +use dao_proposal_single::query::ProposalResponse; +use dao_voting::voting::Vote; +use test_context::test_context; + +use crate::helpers::{ + chain::Chain, + helper::{create_dao, create_proposal, execute, stake_tokens, vote}, +}; + +use super::dao_voting_cw721_staked_test::instantiate_cw721_base; + +fn mint_mint_mint_mint(cw721: &str, owner: &str, mints: u64) -> Vec { + (0..mints) + .map(|mint| { + WasmMsg::Execute { + contract_addr: cw721.to_string(), + msg: to_binary(&cw721_base::msg::ExecuteMsg::Mint::{ + token_id: mint.to_string(), + owner: owner.to_string(), + token_uri: Some("https://bafkreibufednctf2f2bpduiibgkvpqcw5rtdmhqh2htqx3qbdnji4h55hy.ipfs.nftstorage.link".to_string()), + extension: Empty::default(), + }, + ) + .unwrap(), + funds: vec![], + } + .into() + }) + .collect() +} + +/// tests that the maximum number of NFTs creatable in a proposal does +/// not decrease over time. this test failing means that our proposal +/// gas usage as gotten worse. +#[test_context(Chain)] +#[test] +#[ignore] +fn how_many_nfts_can_be_minted_in_one_proposal(chain: &mut Chain) { + let user_addr = chain.users["user1"].account.address.clone(); + let user_key = chain.users["user1"].key.clone(); + + let dao = create_dao(chain, None, "create_dao", user_addr.clone(), &user_key).unwrap(); + let cw721 = instantiate_cw721_base(chain, &user_key, &dao.addr); + + // limit selected with tuning method below. + let msgs = mint_mint_mint_mint(&cw721, &user_addr, 55); + stake_tokens(chain, 1, &user_key); + let ProposalResponse { id, .. } = create_proposal(chain, msgs, &user_key).unwrap(); + vote(chain, id, Vote::Yes, &user_key); + execute(chain, id, &user_key); + + // for re-tuning the limit, this may be helpful: + // + // ``` + // for i in 11..20 { + // let msgs = mint_mint_mint_mint(&cw721, &user_addr, 5 * i); + // stake_tokens(chain, 1, &user_key); + // let ProposalResponse { id, .. } = create_proposal(chain, msgs, &user_key).unwrap(); + // vote(chain, id, Vote::Yes, &user_key); + // execute(chain, id, &user_key); + // eprintln!("minted {}", 5 * 11); + // } + // ``` +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..0d88b64a9 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,5 @@ +ignore: + - "ci" + - "packages/dao-dao-macros" + - "packages/dao-testing" + - "test-contracts" diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 000000000..88c0f6e83 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,12 @@ +# DAO Contracts + +- `dao-dao-core` - the core module for DAOs. +- `external` - contracts used by DAOs that are not part of a DAO + module. +- `pre-propose` - pre-propose modules. +- `proposal` - proposal modules. +- `voting` - voting modules. +- `staking` - cw20 staking functionality and a staking rewards + system. These contracts are used by [Wasmswap](https://github.com/Wasmswap) as well as DAO DAO. + +For a description of each module type, see [our wiki](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design). diff --git a/contracts/dao-dao-core/.cargo/config b/contracts/dao-dao-core/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/dao-dao-core/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/dao-dao-core/Cargo.toml b/contracts/dao-dao-core/Cargo.toml new file mode 100644 index 000000000..ac8e2060d --- /dev/null +++ b/contracts/dao-dao-core/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "dao-dao-core" +authors = ["ekez "] +description = "A DAO DAO core module." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true, features = ["ibc3"] } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw-utils = { workspace = true } +cw20 = { workspace = true } +cw721 = { workspace = true } +thiserror = { workspace = true } +dao-interface = { workspace = true } +dao-dao-macros = { workspace = true } +cw-paginate-storage = { workspace = true } +cw-core-v1 = { workspace = true, features = ["library"] } + +[dev-dependencies] +cw-multi-test = { workspace = true, features = ["stargate"] } +cw20-base = { workspace = true } +cw721-base = { workspace = true } +dao-proposal-sudo = { workspace = true } +dao-voting-cw20-balance = { workspace = true } diff --git a/contracts/dao-dao-core/README.md b/contracts/dao-dao-core/README.md new file mode 100644 index 000000000..148b1e0ef --- /dev/null +++ b/contracts/dao-dao-core/README.md @@ -0,0 +1,80 @@ +# dao-dao-core + +This contract is the core module for all DAO DAO DAOs. It handles +management of voting power and proposal modules, executes messages, +and holds the DAO's treasury. + +For more information about how these modules fit together see +[this wiki page](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design). + +In addition to the wiki spec this contract may also pause. To do so a +`Pause` message must be executed by a proposal module. Pausing the +core module will stop all actions on the module for the duration of +the pause. + +## Developing +Core messages and interfaces are defined in the [dao-interfaces](../../packages/dao-interface) package. If you are building new modules or a contract that interacts with a DAO, use `dao-interface`. + +## Treasury management + +For management of non-native assets this contract maintains a list of +[cw20](https://github.com/CosmWasm/cw-plus/tree/1568d9f7796ef93747e5e5e45484447fddbea80b/packages/cw20) +and +[cw721](https://github.com/CosmWasm/cw-nfts/tree/c7be7aba9fb270abefee5a3696be62f2736592a0/packages/cw721) +tokens who's balances the DAO would like to track. This allows +frontends to list these tokens in the DAO's treasury. This tracking is +needed as, for non-native tokens, there is no on-chain data source for +all of the cw20 and cw721 tokens owned by a DAO. It may also help +reduce spam as random shitcoins sent to the DAO won't be displayed in +treasury listings, unless the DAO approves them. + +For native tokens we do not need this additional tracking step, as +native token balances are stored in the [bank +module](https://github.com/cosmos/cosmos-sdk/tree/main/x/bank). Thus, +for those tokens frontends can query the chain directly to discover +which tokens the DAO owns. + +### Managing the treasury + +There are two ways that a non-native token may be added to the DAO +treasury. + +If `automatically_add_[cw20s|cw721s]` is set to true in the [DAO's +config](https://github.com/DA0-DA0/dao-contracts/blob/74bd3881fdd86829e5e8b132b9952dd64f2d0737/contracts/dao-dao/src/state.rs#L16-L21), +the DAO will add the token to the treasury upon receiving the token +via cw20's `Send` method and cw721's `SendNft` method. + +``` +pub enum ExecuteMsg { + /// Executed when the contract receives a cw20 token. Depending on + /// the contract's configuration the contract will automatically + /// add the token to its treasury. + #[cfg(feature = "cw20")] + Receive(cw20::Cw20ReceiveMsg), + /// Executed when the contract receives a cw721 token. Depending + /// on the contract's configuration the contract will + /// automatically add the token to its treasury. + ReceiveNft(cw721::Cw721ReceiveMsg), + // ... +} +``` + +The DAO may always add or remove non-native tokens via the +`UpdateCw20List` and `UpdateCw721List` methods: + +```rust +pub enum ExecuteMsg { + /// Updates the list of cw20 tokens this contract has registered. + #[cfg(feature = "cw20")] + UpdateCw20List { + to_add: Vec, + to_remove: Vec, + }, + /// Updates the list of cw721 tokens this contract has registered. + UpdateCw721List { + to_add: Vec, + to_remove: Vec, + }, + // ... +} +``` diff --git a/contracts/dao-dao-core/examples/schema.rs b/contracts/dao-dao-core/examples/schema.rs new file mode 100644 index 000000000..419bd675d --- /dev/null +++ b/contracts/dao-dao-core/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_interface::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/dao-dao-core/schema/dao-dao-core.json b/contracts/dao-dao-core/schema/dao-dao-core.json new file mode 100644 index 000000000..fec05f159 --- /dev/null +++ b/contracts/dao-dao-core/schema/dao-dao-core.json @@ -0,0 +1,3097 @@ +{ + "contract_name": "dao-dao-core", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "automatically_add_cw20s", + "automatically_add_cw721s", + "description", + "name", + "proposal_modules_instantiate_info", + "voting_module_instantiate_info" + ], + "properties": { + "admin": { + "description": "Optional Admin with the ability to execute DAO messages directly. Useful for building SubDAOs controlled by a parent DAO. If no admin is specified the contract is set as its own admin so that the admin may be updated later by governance.", + "type": [ + "string", + "null" + ] + }, + "automatically_add_cw20s": { + "description": "If true the contract will automatically add received cw20 tokens to its treasury.", + "type": "boolean" + }, + "automatically_add_cw721s": { + "description": "If true the contract will automatically add received cw721 tokens to its treasury.", + "type": "boolean" + }, + "dao_uri": { + "description": "Implements the DAO Star standard: ", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "A description of the core contract.", + "type": "string" + }, + "image_url": { + "description": "An image URL to describe the core module contract.", + "type": [ + "string", + "null" + ] + }, + "initial_items": { + "description": "The items to instantiate this DAO with. Items are arbitrary key-value pairs whose contents are controlled by governance.\n\nIt is an error to provide two items with the same key.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/InitialItem" + } + }, + "name": { + "description": "The name of the core contract.", + "type": "string" + }, + "proposal_modules_instantiate_info": { + "description": "Instantiate information for the core contract's proposal modules. NOTE: the pre-propose-base package depends on it being the case that the core module instantiates its proposal module.", + "type": "array", + "items": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "voting_module_instantiate_info": { + "description": "Instantiate information for the core contract's voting power module.", + "allOf": [ + { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "InitialItem": { + "description": "Information about an item to be stored in the items list.", + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "description": "The name of the item.", + "type": "string" + }, + "value": { + "description": "The value the item will have at instantiation time.", + "type": "string" + } + }, + "additionalProperties": false + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Callable by the Admin, if one is configured. Executes messages in order.", + "type": "object", + "required": [ + "execute_admin_msgs" + ], + "properties": { + "execute_admin_msgs": { + "type": "object", + "required": [ + "msgs" + ], + "properties": { + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Callable by proposal modules. The DAO will execute the messages in the hook in order.", + "type": "object", + "required": [ + "execute_proposal_hook" + ], + "properties": { + "execute_proposal_hook": { + "type": "object", + "required": [ + "msgs" + ], + "properties": { + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Pauses the DAO for a set duration. When paused the DAO is unable to execute proposals", + "type": "object", + "required": [ + "pause" + ], + "properties": { + "pause": { + "type": "object", + "required": [ + "duration" + ], + "properties": { + "duration": { + "$ref": "#/definitions/Duration" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Executed when the contract receives a cw20 token. Depending on the contract's configuration the contract will automatically add the token to its treasury.", + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Executed when the contract receives a cw721 token. Depending on the contract's configuration the contract will automatically add the token to its treasury.", + "type": "object", + "required": [ + "receive_nft" + ], + "properties": { + "receive_nft": { + "$ref": "#/definitions/Cw721ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Removes an item from the governance contract's item map.", + "type": "object", + "required": [ + "remove_item" + ], + "properties": { + "remove_item": { + "type": "object", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds an item to the governance contract's item map. If the item already exists the existing value is overridden. If the item does not exist a new item is added.", + "type": "object", + "required": [ + "set_item" + ], + "properties": { + "set_item": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Callable by the admin of the contract. If ADMIN is None the admin is set as the contract itself so that it may be updated later by vote. If ADMIN is Some a new admin is proposed and that new admin may become the admin by executing the `AcceptAdminNomination` message.\n\nIf there is already a pending admin nomination the `WithdrawAdminNomination` message must be executed before a new admin may be nominated.", + "type": "object", + "required": [ + "nominate_admin" + ], + "properties": { + "nominate_admin": { + "type": "object", + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Callable by a nominated admin. Admins are nominated via the `NominateAdmin` message. Accepting a nomination will make the nominated address the new admin.\n\nRequiring that the new admin accepts the nomination before becoming the admin protects against a typo causing the admin to change to an invalid address.", + "type": "object", + "required": [ + "accept_admin_nomination" + ], + "properties": { + "accept_admin_nomination": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Callable by the current admin. Withdraws the current admin nomination.", + "type": "object", + "required": [ + "withdraw_admin_nomination" + ], + "properties": { + "withdraw_admin_nomination": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Callable by the core contract. Replaces the current governance contract config with the provided config.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the list of cw20 tokens this contract has registered.", + "type": "object", + "required": [ + "update_cw20_list" + ], + "properties": { + "update_cw20_list": { + "type": "object", + "required": [ + "to_add", + "to_remove" + ], + "properties": { + "to_add": { + "type": "array", + "items": { + "type": "string" + } + }, + "to_remove": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the list of cw721 tokens this contract has registered.", + "type": "object", + "required": [ + "update_cw721_list" + ], + "properties": { + "update_cw721_list": { + "type": "object", + "required": [ + "to_add", + "to_remove" + ], + "properties": { + "to_add": { + "type": "array", + "items": { + "type": "string" + } + }, + "to_remove": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the governance contract's governance modules. Module instantiate info in `to_add` is used to create new modules and install them.", + "type": "object", + "required": [ + "update_proposal_modules" + ], + "properties": { + "update_proposal_modules": { + "type": "object", + "required": [ + "to_add", + "to_disable" + ], + "properties": { + "to_add": { + "description": "NOTE: the pre-propose-base package depends on it being the case that the core module instantiates its proposal module.", + "type": "array", + "items": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "to_disable": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Callable by the core contract. Replaces the current voting module with a new one instantiated by the governance contract.", + "type": "object", + "required": [ + "update_voting_module" + ], + "properties": { + "update_voting_module": { + "type": "object", + "required": [ + "module" + ], + "properties": { + "module": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update the core module to add/remove SubDAOs and their charters", + "type": "object", + "required": [ + "update_sub_daos" + ], + "properties": { + "update_sub_daos": { + "type": "object", + "required": [ + "to_add", + "to_remove" + ], + "properties": { + "to_add": { + "type": "array", + "items": { + "$ref": "#/definitions/SubDao" + } + }, + "to_remove": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "Config": { + "description": "Top level config type for core module.", + "type": "object", + "required": [ + "automatically_add_cw20s", + "automatically_add_cw721s", + "description", + "name" + ], + "properties": { + "automatically_add_cw20s": { + "description": "If true the contract will automatically add received cw20 tokens to its treasury.", + "type": "boolean" + }, + "automatically_add_cw721s": { + "description": "If true the contract will automatically add received cw721 tokens to its treasury.", + "type": "boolean" + }, + "dao_uri": { + "description": "The URI for the DAO as defined by the DAOstar standard ", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "A description of the contract.", + "type": "string" + }, + "image_url": { + "description": "An optional image URL for displaying alongside the contract.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The name of the contract.", + "type": "string" + } + }, + "additionalProperties": false + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "additionalProperties": false + } + ] + }, + "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 + }, + "Cw721ReceiveMsg": { + "description": "Cw721ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg", + "type": "object", + "required": [ + "msg", + "sender", + "token_id" + ], + "properties": { + "msg": { + "$ref": "#/definitions/Binary" + }, + "sender": { + "type": "string" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "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 + } + ] + }, + "Empty": { + "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" + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "SubDao": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "description": "The contract address of the SubDAO", + "type": "string" + }, + "charter": { + "description": "The purpose/constitution for the SubDAO", + "type": [ + "string", + "null" + ] + } + }, + "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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Get's the DAO's admin. Returns `Addr`.", + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Get's the currently nominated admin (if any).", + "type": "object", + "required": [ + "admin_nomination" + ], + "properties": { + "admin_nomination": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the contract's config.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the token balance for each cw20 registered with the contract.", + "type": "object", + "required": [ + "cw20_balances" + ], + "properties": { + "cw20_balances": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists the addresses of the cw20 tokens in this contract's treasury.", + "type": "object", + "required": [ + "cw20_token_list" + ], + "properties": { + "cw20_token_list": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists the addresses of the cw721 tokens in this contract's treasury.", + "type": "object", + "required": [ + "cw721_token_list" + ], + "properties": { + "cw721_token_list": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Dumps all of the core contract's state in a single query. Useful for frontends as performance for queries is more limited by network times than compute times.", + "type": "object", + "required": [ + "dump_state" + ], + "properties": { + "dump_state": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the address associated with an item key.", + "type": "object", + "required": [ + "get_item" + ], + "properties": { + "get_item": { + "type": "object", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the items associted with the contract. For example, given the items `{ \"group\": \"foo\", \"subdao\": \"bar\"}` this query would return `[(\"group\", \"foo\"), (\"subdao\", \"bar\")]`.", + "type": "object", + "required": [ + "list_items" + ], + "properties": { + "list_items": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns contract version info", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets all proposal modules associated with the contract.", + "type": "object", + "required": [ + "proposal_modules" + ], + "properties": { + "proposal_modules": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the active proposal modules associated with the contract.", + "type": "object", + "required": [ + "active_proposal_modules" + ], + "properties": { + "active_proposal_modules": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the number of active and total proposal modules registered with this module.", + "type": "object", + "required": [ + "proposal_module_count" + ], + "properties": { + "proposal_module_count": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns information about if the contract is currently paused.", + "type": "object", + "required": [ + "pause_info" + ], + "properties": { + "pause_info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the contract's voting module.", + "type": "object", + "required": [ + "voting_module" + ], + "properties": { + "voting_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns all SubDAOs with their charters in a vec. start_after is bound exclusive and asks for a string address.", + "type": "object", + "required": [ + "list_sub_daos" + ], + "properties": { + "list_sub_daos": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Implements the DAO Star standard: ", + "type": "object", + "required": [ + "dao_u_r_i" + ], + "properties": { + "dao_u_r_i": { + "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 height.", + "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 + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "from_v1" + ], + "properties": { + "from_v1": { + "type": "object", + "properties": { + "dao_uri": { + "type": [ + "string", + "null" + ] + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/MigrateParams" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "from_compatible" + ], + "properties": { + "from_compatible": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "MigrateParams": { + "type": "object", + "required": [ + "migrator_code_id", + "params" + ], + "properties": { + "migrator_code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "params": { + "$ref": "#/definitions/MigrateV1ToV2" + } + }, + "additionalProperties": false + }, + "MigrateV1ToV2": { + "type": "object", + "required": [ + "migration_params", + "sub_daos", + "v1_code_ids", + "v2_code_ids" + ], + "properties": { + "migration_params": { + "$ref": "#/definitions/MigrationModuleParams" + }, + "sub_daos": { + "type": "array", + "items": { + "$ref": "#/definitions/SubDao" + } + }, + "v1_code_ids": { + "$ref": "#/definitions/V1CodeIds" + }, + "v2_code_ids": { + "$ref": "#/definitions/V2CodeIds" + } + }, + "additionalProperties": false + }, + "MigrationModuleParams": { + "type": "object", + "required": [ + "proposal_params" + ], + "properties": { + "migrate_stake_cw20_manager": { + "description": "Rather or not to migrate the stake_cw20 contract and its manager. If this is not set to true and a stake_cw20 contract is detected in the DAO's configuration the migration will be aborted.", + "type": [ + "boolean", + "null" + ] + }, + "proposal_params": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ProposalParams" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + }, + "additionalProperties": false + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "ProposalParams": { + "description": "The params we need to provide for migration msgs", + "type": "object", + "required": [ + "close_proposal_on_execution_failure", + "pre_propose_info" + ], + "properties": { + "close_proposal_on_execution_failure": { + "type": "boolean" + }, + "pre_propose_info": { + "$ref": "#/definitions/PreProposeInfo" + } + }, + "additionalProperties": false + }, + "SubDao": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "description": "The contract address of the SubDAO", + "type": "string" + }, + "charter": { + "description": "The purpose/constitution for the SubDAO", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "V1CodeIds": { + "type": "object", + "required": [ + "cw20_stake", + "cw20_staked_balances_voting", + "cw4_voting", + "proposal_single" + ], + "properties": { + "cw20_stake": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "cw20_staked_balances_voting": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "cw4_voting": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal_single": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "V2CodeIds": { + "type": "object", + "required": [ + "cw20_stake", + "cw20_staked_balances_voting", + "cw4_voting", + "proposal_single" + ], + "properties": { + "cw20_stake": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "cw20_staked_balances_voting": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "cw4_voting": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal_single": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "sudo": null, + "responses": { + "active_proposal_modules": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_ProposalModule", + "type": "array", + "items": { + "$ref": "#/definitions/ProposalModule" + }, + "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" + }, + "ProposalModule": { + "description": "Top level type describing a proposal module.", + "type": "object", + "required": [ + "address", + "prefix", + "status" + ], + "properties": { + "address": { + "description": "The address of the proposal module.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "prefix": { + "description": "The URL prefix of this proposal module as derived from the module ID. Prefixes are mapped to letters, e.g. 0 is 'A', and 26 is 'AA'.", + "type": "string" + }, + "status": { + "description": "The status of the proposal module, e.g. 'Enabled' or 'Disabled.'", + "allOf": [ + { + "$ref": "#/definitions/ProposalModuleStatus" + } + ] + } + }, + "additionalProperties": false + }, + "ProposalModuleStatus": { + "description": "The status of a proposal module.", + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "admin": { + "$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" + }, + "admin_nomination": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AdminNominationResponse", + "description": "Returned by the `AdminNomination` query.", + "type": "object", + "properties": { + "nomination": { + "description": "The currently nominated admin or None if no nomination is pending.", + "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" + } + } + }, + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "Top level config type for core module.", + "type": "object", + "required": [ + "automatically_add_cw20s", + "automatically_add_cw721s", + "description", + "name" + ], + "properties": { + "automatically_add_cw20s": { + "description": "If true the contract will automatically add received cw20 tokens to its treasury.", + "type": "boolean" + }, + "automatically_add_cw721s": { + "description": "If true the contract will automatically add received cw721 tokens to its treasury.", + "type": "boolean" + }, + "dao_uri": { + "description": "The URI for the DAO as defined by the DAOstar standard ", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "A description of the contract.", + "type": "string" + }, + "image_url": { + "description": "An optional image URL for displaying alongside the contract.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The name of the contract.", + "type": "string" + } + }, + "additionalProperties": false + }, + "cw20_balances": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Cw20BalanceResponse", + "description": "Returned by the `Cw20Balances` query.", + "type": "object", + "required": [ + "addr", + "balance" + ], + "properties": { + "addr": { + "description": "The address of the token.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "balance": { + "description": "The contract's balance.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + } + }, + "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" + }, + "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" + } + } + }, + "cw20_token_list": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_Addr", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + }, + "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" + } + } + }, + "cw721_token_list": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_Addr", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + }, + "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" + } + } + }, + "dao_u_r_i": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DaoURIResponse", + "type": "object", + "properties": { + "dao_uri": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "dump_state": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DumpStateResponse", + "description": "Relevant state for the governance module. Returned by the `DumpState` query.", + "type": "object", + "required": [ + "active_proposal_module_count", + "admin", + "config", + "pause_info", + "proposal_modules", + "total_proposal_module_count", + "version", + "voting_module" + ], + "properties": { + "active_proposal_module_count": { + "description": "The number of active proposal modules.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "admin": { + "description": "Optional DAO Admin", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "config": { + "description": "The governance contract's config.", + "allOf": [ + { + "$ref": "#/definitions/Config" + } + ] + }, + "pause_info": { + "$ref": "#/definitions/PauseInfoResponse" + }, + "proposal_modules": { + "description": "The governance modules associated with the governance contract.", + "type": "array", + "items": { + "$ref": "#/definitions/ProposalModule" + } + }, + "total_proposal_module_count": { + "description": "The total number of proposal modules.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "version": { + "description": "The governance contract's version.", + "allOf": [ + { + "$ref": "#/definitions/ContractVersion" + } + ] + }, + "voting_module": { + "description": "The voting module associated with the governance contract.", + "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" + }, + "Config": { + "description": "Top level config type for core module.", + "type": "object", + "required": [ + "automatically_add_cw20s", + "automatically_add_cw721s", + "description", + "name" + ], + "properties": { + "automatically_add_cw20s": { + "description": "If true the contract will automatically add received cw20 tokens to its treasury.", + "type": "boolean" + }, + "automatically_add_cw721s": { + "description": "If true the contract will automatically add received cw721 tokens to its treasury.", + "type": "boolean" + }, + "dao_uri": { + "description": "The URI for the DAO as defined by the DAOstar standard ", + "type": [ + "string", + "null" + ] + }, + "description": { + "description": "A description of the contract.", + "type": "string" + }, + "image_url": { + "description": "An optional image URL for displaying alongside the contract.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "The name of the contract.", + "type": "string" + } + }, + "additionalProperties": false + }, + "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 + }, + "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 + } + ] + }, + "PauseInfoResponse": { + "description": "Information about if the contract is currently paused.", + "oneOf": [ + { + "type": "object", + "required": [ + "paused" + ], + "properties": { + "paused": { + "type": "object", + "required": [ + "expiration" + ], + "properties": { + "expiration": { + "$ref": "#/definitions/Expiration" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unpaused" + ], + "properties": { + "unpaused": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "ProposalModule": { + "description": "Top level type describing a proposal module.", + "type": "object", + "required": [ + "address", + "prefix", + "status" + ], + "properties": { + "address": { + "description": "The address of the proposal module.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "prefix": { + "description": "The URL prefix of this proposal module as derived from the module ID. Prefixes are mapped to letters, e.g. 0 is 'A', and 26 is 'AA'.", + "type": "string" + }, + "status": { + "description": "The status of the proposal module, e.g. 'Enabled' or 'Disabled.'", + "allOf": [ + { + "$ref": "#/definitions/ProposalModuleStatus" + } + ] + } + }, + "additionalProperties": false + }, + "ProposalModuleStatus": { + "description": "The status of a proposal module.", + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "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" + } + } + }, + "get_item": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetItemResponse", + "description": "Returned by the `GetItem` query.", + "type": "object", + "properties": { + "item": { + "description": "`None` if no item with the provided key was found, `Some` otherwise.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "additionalProperties": false, + "definitions": { + "ContractVersion": { + "type": "object", + "required": [ + "contract", + "version" + ], + "properties": { + "contract": { + "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", + "type": "string" + }, + "version": { + "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_items": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } + }, + "list_sub_daos": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_SubDao", + "type": "array", + "items": { + "$ref": "#/definitions/SubDao" + }, + "definitions": { + "SubDao": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "description": "The contract address of the SubDAO", + "type": "string" + }, + "charter": { + "description": "The purpose/constitution for the SubDAO", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + } + }, + "pause_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PauseInfoResponse", + "description": "Information about if the contract is currently paused.", + "oneOf": [ + { + "type": "object", + "required": [ + "paused" + ], + "properties": { + "paused": { + "type": "object", + "required": [ + "expiration" + ], + "properties": { + "expiration": { + "$ref": "#/definitions/Expiration" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unpaused" + ], + "properties": { + "unpaused": { + "type": "object", + "additionalProperties": false + } + }, + "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 + } + ] + }, + "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" + } + } + }, + "proposal_module_count": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalModuleCountResponse", + "type": "object", + "required": [ + "active_proposal_module_count", + "total_proposal_module_count" + ], + "properties": { + "active_proposal_module_count": { + "description": "The number of active proposal modules.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "total_proposal_module_count": { + "description": "The total number of proposal modules.", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "proposal_modules": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_ProposalModule", + "type": "array", + "items": { + "$ref": "#/definitions/ProposalModule" + }, + "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" + }, + "ProposalModule": { + "description": "Top level type describing a proposal module.", + "type": "object", + "required": [ + "address", + "prefix", + "status" + ], + "properties": { + "address": { + "description": "The address of the proposal module.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "prefix": { + "description": "The URL prefix of this proposal module as derived from the module ID. Prefixes are mapped to letters, e.g. 0 is 'A', and 26 is 'AA'.", + "type": "string" + }, + "status": { + "description": "The status of the proposal module, e.g. 'Enabled' or 'Disabled.'", + "allOf": [ + { + "$ref": "#/definitions/ProposalModuleStatus" + } + ] + } + }, + "additionalProperties": false + }, + "ProposalModuleStatus": { + "description": "The status of a proposal module.", + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + } + } + }, + "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_module": { + "$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" + }, + "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/dao-dao-core/src/contract.rs b/contracts/dao-dao-core/src/contract.rs new file mode 100644 index 000000000..97f3a7d3e --- /dev/null +++ b/contracts/dao-dao-core/src/contract.rs @@ -0,0 +1,1053 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + from_binary, to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Order, + Reply, Response, StdError, StdResult, SubMsg, WasmMsg, +}; +use cw2::{get_contract_version, set_contract_version, ContractVersion}; +use cw_paginate_storage::{paginate_map, paginate_map_keys, paginate_map_values}; +use cw_storage_plus::Map; +use cw_utils::{parse_reply_instantiate_data, Duration}; +use dao_interface::{ + msg::{ExecuteMsg, InitialItem, InstantiateMsg, MigrateMsg, QueryMsg}, + query::{ + AdminNominationResponse, Cw20BalanceResponse, DaoURIResponse, DumpStateResponse, + GetItemResponse, PauseInfoResponse, ProposalModuleCountResponse, SubDao, + }, + state::{ + Admin, Config, ModuleInstantiateCallback, ModuleInstantiateInfo, ProposalModule, + ProposalModuleStatus, + }, + voting, +}; + +use crate::error::ContractError; +use crate::state::{ + ACTIVE_PROPOSAL_MODULE_COUNT, ADMIN, CONFIG, CW20_LIST, CW721_LIST, ITEMS, NOMINATED_ADMIN, + PAUSED, PROPOSAL_MODULES, SUBDAO_LIST, TOTAL_PROPOSAL_MODULE_COUNT, VOTING_MODULE, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-dao-core"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const PROPOSAL_MODULE_REPLY_ID: u64 = 0; +const VOTE_MODULE_INSTANTIATE_REPLY_ID: u64 = 1; +const VOTE_MODULE_UPDATE_REPLY_ID: u64 = 2; + +#[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)?; + + let config = Config { + name: msg.name, + description: msg.description, + image_url: msg.image_url, + automatically_add_cw20s: msg.automatically_add_cw20s, + automatically_add_cw721s: msg.automatically_add_cw721s, + dao_uri: msg.dao_uri, + }; + CONFIG.save(deps.storage, &config)?; + + let admin = msg + .admin + .map(|human| deps.api.addr_validate(&human)) + .transpose()? + // If no admin is specified, the contract is its own admin. + .unwrap_or_else(|| env.contract.address.clone()); + ADMIN.save(deps.storage, &admin)?; + + let vote_module_msg = msg + .voting_module_instantiate_info + .into_wasm_msg(env.contract.address.clone()); + let vote_module_msg: SubMsg = + SubMsg::reply_on_success(vote_module_msg, VOTE_MODULE_INSTANTIATE_REPLY_ID); + + let proposal_module_msgs: Vec> = msg + .proposal_modules_instantiate_info + .into_iter() + .map(|info| info.into_wasm_msg(env.contract.address.clone())) + .map(|wasm| SubMsg::reply_on_success(wasm, PROPOSAL_MODULE_REPLY_ID)) + .collect(); + if proposal_module_msgs.is_empty() { + return Err(ContractError::NoActiveProposalModules {}); + } + + if let Some(initial_items) = msg.initial_items { + // O(N*N) deduplication. + let mut seen = Vec::with_capacity(initial_items.len()); + for InitialItem { key, value } in initial_items { + if seen.contains(&key) { + return Err(ContractError::DuplicateInitialItem { item: key }); + } + seen.push(key.clone()); + ITEMS.save(deps.storage, key, &value)?; + } + } + + TOTAL_PROPOSAL_MODULE_COUNT.save(deps.storage, &0)?; + ACTIVE_PROPOSAL_MODULE_COUNT.save(deps.storage, &0)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("sender", info.sender) + .add_submessage(vote_module_msg) + .add_submessages(proposal_module_msgs)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + // No actions can be performed while the DAO is paused. + if let Some(expiration) = PAUSED.may_load(deps.storage)? { + if !expiration.is_expired(&env.block) { + return Err(ContractError::Paused {}); + } + } + + match msg { + ExecuteMsg::ExecuteAdminMsgs { msgs } => { + execute_admin_msgs(deps.as_ref(), info.sender, msgs) + } + ExecuteMsg::ExecuteProposalHook { msgs } => { + execute_proposal_hook(deps.as_ref(), info.sender, msgs) + } + ExecuteMsg::Pause { duration } => execute_pause(deps, env, info.sender, duration), + ExecuteMsg::Receive(_) => execute_receive_cw20(deps, info.sender), + ExecuteMsg::ReceiveNft(_) => execute_receive_cw721(deps, info.sender), + ExecuteMsg::RemoveItem { key } => execute_remove_item(deps, env, info.sender, key), + ExecuteMsg::SetItem { key, value } => execute_set_item(deps, env, info.sender, key, value), + ExecuteMsg::UpdateConfig { config } => { + execute_update_config(deps, env, info.sender, config) + } + ExecuteMsg::UpdateCw20List { to_add, to_remove } => { + execute_update_cw20_list(deps, env, info.sender, to_add, to_remove) + } + ExecuteMsg::UpdateCw721List { to_add, to_remove } => { + execute_update_cw721_list(deps, env, info.sender, to_add, to_remove) + } + ExecuteMsg::UpdateVotingModule { module } => { + execute_update_voting_module(env, info.sender, module) + } + ExecuteMsg::UpdateProposalModules { to_add, to_disable } => { + execute_update_proposal_modules(deps, env, info.sender, to_add, to_disable) + } + ExecuteMsg::NominateAdmin { admin } => { + execute_nominate_admin(deps, env, info.sender, admin) + } + ExecuteMsg::AcceptAdminNomination {} => execute_accept_admin_nomination(deps, info.sender), + ExecuteMsg::WithdrawAdminNomination {} => { + execute_withdraw_admin_nomination(deps, info.sender) + } + ExecuteMsg::UpdateSubDaos { to_add, to_remove } => { + execute_update_sub_daos_list(deps, env, info.sender, to_add, to_remove) + } + } +} + +pub fn execute_pause( + deps: DepsMut, + env: Env, + sender: Addr, + pause_duration: Duration, +) -> Result { + // Only the core contract may call this method. + if sender != env.contract.address { + return Err(ContractError::Unauthorized {}); + } + + let until = pause_duration.after(&env.block); + + PAUSED.save(deps.storage, &until)?; + + Ok(Response::new() + .add_attribute("action", "execute_pause") + .add_attribute("sender", sender) + .add_attribute("until", until.to_string())) +} + +pub fn execute_admin_msgs( + deps: Deps, + sender: Addr, + msgs: Vec>, +) -> Result { + let admin = ADMIN.load(deps.storage)?; + + // Check if the sender is the DAO Admin + if sender != admin { + return Err(ContractError::Unauthorized {}); + } + + Ok(Response::default() + .add_attribute("action", "execute_admin_msgs") + .add_messages(msgs)) +} + +pub fn execute_proposal_hook( + deps: Deps, + sender: Addr, + msgs: Vec>, +) -> Result { + let module = PROPOSAL_MODULES + .may_load(deps.storage, sender.clone())? + .ok_or(ContractError::Unauthorized {})?; + + // Check that the message has come from an active module + if module.status != ProposalModuleStatus::Enabled { + return Err(ContractError::ModuleDisabledCannotExecute { address: sender }); + } + + Ok(Response::default() + .add_attribute("action", "execute_proposal_hook") + .add_messages(msgs)) +} + +pub fn execute_nominate_admin( + deps: DepsMut, + env: Env, + sender: Addr, + nomination: Option, +) -> Result { + let nomination = nomination.map(|h| deps.api.addr_validate(&h)).transpose()?; + + let current_admin = ADMIN.load(deps.storage)?; + if current_admin != sender { + return Err(ContractError::Unauthorized {}); + } + + let current_nomination = NOMINATED_ADMIN.may_load(deps.storage)?; + if current_nomination.is_some() { + return Err(ContractError::PendingNomination {}); + } + + match &nomination { + Some(nomination) => NOMINATED_ADMIN.save(deps.storage, nomination)?, + // If no admin set to default of the contract. This allows the + // contract to later set a new admin via governance. + None => ADMIN.save(deps.storage, &env.contract.address)?, + } + + Ok(Response::default() + .add_attribute("action", "execute_nominate_admin") + .add_attribute( + "nomination", + nomination + .map(|n| n.to_string()) + .unwrap_or_else(|| "None".to_string()), + )) +} + +pub fn execute_accept_admin_nomination( + deps: DepsMut, + sender: Addr, +) -> Result { + let nomination = NOMINATED_ADMIN + .may_load(deps.storage)? + .ok_or(ContractError::NoAdminNomination {})?; + if sender != nomination { + return Err(ContractError::Unauthorized {}); + } + NOMINATED_ADMIN.remove(deps.storage); + ADMIN.save(deps.storage, &nomination)?; + + Ok(Response::default() + .add_attribute("action", "execute_accept_admin_nomination") + .add_attribute("new_admin", sender)) +} + +pub fn execute_withdraw_admin_nomination( + deps: DepsMut, + sender: Addr, +) -> Result { + let admin = ADMIN.load(deps.storage)?; + if admin != sender { + return Err(ContractError::Unauthorized {}); + } + + // Check that there is indeed a nomination to withdraw. + let current_nomination = NOMINATED_ADMIN.may_load(deps.storage)?; + if current_nomination.is_none() { + return Err(ContractError::NoAdminNomination {}); + } + + NOMINATED_ADMIN.remove(deps.storage); + + Ok(Response::default() + .add_attribute("action", "execute_withdraw_admin_nomination") + .add_attribute("sender", sender)) +} + +pub fn execute_update_config( + deps: DepsMut, + env: Env, + sender: Addr, + config: Config, +) -> Result { + if sender != env.contract.address { + return Err(ContractError::Unauthorized {}); + } + + CONFIG.save(deps.storage, &config)?; + // We incur some gas costs by having the config's fields in the + // response. This has the benefit that it makes it reasonably + // simple to ask "when did this field in the config change" by + // running something like `junod query txs --events + // 'wasm._contract_address=core&wasm.name=name'`. + Ok(Response::default() + .add_attribute("action", "execute_update_config") + .add_attribute("name", config.name) + .add_attribute("description", config.description) + .add_attribute( + "image_url", + config.image_url.unwrap_or_else(|| "None".to_string()), + )) +} + +pub fn execute_update_voting_module( + env: Env, + sender: Addr, + module: ModuleInstantiateInfo, +) -> Result { + if env.contract.address != sender { + return Err(ContractError::Unauthorized {}); + } + + let wasm = module.into_wasm_msg(env.contract.address); + let submessage = SubMsg::reply_on_success(wasm, VOTE_MODULE_UPDATE_REPLY_ID); + + Ok(Response::default() + .add_attribute("action", "execute_update_voting_module") + .add_submessage(submessage)) +} + +pub fn execute_update_proposal_modules( + deps: DepsMut, + env: Env, + sender: Addr, + to_add: Vec, + to_disable: Vec, +) -> Result { + if env.contract.address != sender { + return Err(ContractError::Unauthorized {}); + } + + let disable_count = to_disable.len() as u32; + for addr in to_disable { + let addr = deps.api.addr_validate(&addr)?; + let mut module = PROPOSAL_MODULES + .load(deps.storage, addr.clone()) + .map_err(|_| ContractError::ProposalModuleDoesNotExist { + address: addr.clone(), + })?; + + if module.status == ProposalModuleStatus::Disabled { + return Err(ContractError::ModuleAlreadyDisabled { + address: module.address, + }); + } + + module.status = ProposalModuleStatus::Disabled {}; + PROPOSAL_MODULES.save(deps.storage, addr, &module)?; + } + + // If disabling this module will cause there to be no active modules, return error. + // We don't check the active count before disabling because there may erroneously be + // modules in to_disable which are already disabled. + ACTIVE_PROPOSAL_MODULE_COUNT.update(deps.storage, |count| { + if count <= disable_count && to_add.is_empty() { + return Err(ContractError::NoActiveProposalModules {}); + } + Ok(count - disable_count) + })?; + + let to_add: Vec> = to_add + .into_iter() + .map(|info| info.into_wasm_msg(env.contract.address.clone())) + .map(|wasm| SubMsg::reply_on_success(wasm, PROPOSAL_MODULE_REPLY_ID)) + .collect(); + + Ok(Response::default() + .add_attribute("action", "execute_update_proposal_modules") + .add_submessages(to_add)) +} + +/// Updates a set of addresses in state applying VERIFY to each item +/// that will be added. +fn do_update_addr_list( + deps: DepsMut, + map: Map, + to_add: Vec, + to_remove: Vec, + verify: impl Fn(&Addr, Deps) -> StdResult<()>, +) -> Result<(), ContractError> { + let to_add = to_add + .into_iter() + .map(|a| deps.api.addr_validate(&a)) + .collect::, _>>()?; + + let to_remove = to_remove + .into_iter() + .map(|a| deps.api.addr_validate(&a)) + .collect::, _>>()?; + + for addr in to_add { + verify(&addr, deps.as_ref())?; + map.save(deps.storage, addr, &Empty {})?; + } + for addr in to_remove { + map.remove(deps.storage, addr); + } + + Ok(()) +} + +pub fn execute_update_cw20_list( + deps: DepsMut, + env: Env, + sender: Addr, + to_add: Vec, + to_remove: Vec, +) -> Result { + if env.contract.address != sender { + return Err(ContractError::Unauthorized {}); + } + do_update_addr_list(deps, CW20_LIST, to_add, to_remove, |addr, deps| { + // Perform a balance query here as this is the query performed + // by the `Cw20Balances` query. + let _info: cw20::BalanceResponse = deps.querier.query_wasm_smart( + addr, + &cw20::Cw20QueryMsg::Balance { + address: env.contract.address.to_string(), + }, + )?; + Ok(()) + })?; + Ok(Response::default().add_attribute("action", "update_cw20_list")) +} + +pub fn execute_update_cw721_list( + deps: DepsMut, + env: Env, + sender: Addr, + to_add: Vec, + to_remove: Vec, +) -> Result { + if env.contract.address != sender { + return Err(ContractError::Unauthorized {}); + } + do_update_addr_list(deps, CW721_LIST, to_add, to_remove, |addr, deps| { + let _info: cw721::ContractInfoResponse = deps + .querier + .query_wasm_smart(addr, &cw721::Cw721QueryMsg::ContractInfo {})?; + Ok(()) + })?; + Ok(Response::default().add_attribute("action", "update_cw721_list")) +} + +pub fn execute_set_item( + deps: DepsMut, + env: Env, + sender: Addr, + key: String, + value: String, +) -> Result { + if env.contract.address != sender { + return Err(ContractError::Unauthorized {}); + } + + ITEMS.save(deps.storage, key.clone(), &value)?; + Ok(Response::default() + .add_attribute("action", "execute_set_item") + .add_attribute("key", key) + .add_attribute("addr", value)) +} + +pub fn execute_remove_item( + deps: DepsMut, + env: Env, + sender: Addr, + key: String, +) -> Result { + if env.contract.address != sender { + return Err(ContractError::Unauthorized {}); + } + + if ITEMS.has(deps.storage, key.clone()) { + ITEMS.remove(deps.storage, key.clone()); + Ok(Response::default() + .add_attribute("action", "execute_remove_item") + .add_attribute("key", key)) + } else { + Err(ContractError::KeyMissing {}) + } +} + +pub fn execute_update_sub_daos_list( + deps: DepsMut, + env: Env, + sender: Addr, + to_add: Vec, + to_remove: Vec, +) -> Result { + if env.contract.address != sender { + return Err(ContractError::Unauthorized {}); + } + + for addr in to_remove { + let addr = deps.api.addr_validate(&addr)?; + SUBDAO_LIST.remove(deps.storage, &addr); + } + + for subdao in to_add { + let addr = deps.api.addr_validate(&subdao.addr)?; + SUBDAO_LIST.save(deps.storage, &addr, &subdao.charter)?; + } + + Ok(Response::default() + .add_attribute("action", "execute_update_sub_daos_list") + .add_attribute("sender", sender)) +} + +pub fn execute_receive_cw20(deps: DepsMut, sender: Addr) -> Result { + let config = CONFIG.load(deps.storage)?; + if !config.automatically_add_cw20s { + Ok(Response::new()) + } else { + CW20_LIST.save(deps.storage, sender.clone(), &Empty {})?; + Ok(Response::new() + .add_attribute("action", "receive_cw20") + .add_attribute("token", sender)) + } +} + +pub fn execute_receive_cw721(deps: DepsMut, sender: Addr) -> Result { + let config = CONFIG.load(deps.storage)?; + if !config.automatically_add_cw721s { + Ok(Response::new()) + } else { + CW721_LIST.save(deps.storage, sender.clone(), &Empty {})?; + Ok(Response::new() + .add_attribute("action", "receive_cw721") + .add_attribute("token", sender)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Admin {} => query_admin(deps), + QueryMsg::AdminNomination {} => query_admin_nomination(deps), + QueryMsg::Config {} => query_config(deps), + QueryMsg::Cw20TokenList { start_after, limit } => query_cw20_list(deps, start_after, limit), + QueryMsg::Cw20Balances { start_after, limit } => { + query_cw20_balances(deps, env, start_after, limit) + } + QueryMsg::Cw721TokenList { start_after, limit } => { + query_cw721_list(deps, start_after, limit) + } + QueryMsg::DumpState {} => query_dump_state(deps, env), + QueryMsg::GetItem { key } => query_get_item(deps, key), + QueryMsg::Info {} => query_info(deps), + QueryMsg::ListItems { start_after, limit } => query_list_items(deps, start_after, limit), + QueryMsg::PauseInfo {} => query_paused(deps, env), + QueryMsg::ProposalModules { start_after, limit } => { + query_proposal_modules(deps, start_after, limit) + } + QueryMsg::ProposalModuleCount {} => query_proposal_module_count(deps), + QueryMsg::TotalPowerAtHeight { height } => query_total_power_at_height(deps, height), + QueryMsg::VotingModule {} => query_voting_module(deps), + QueryMsg::VotingPowerAtHeight { address, height } => { + query_voting_power_at_height(deps, address, height) + } + QueryMsg::ActiveProposalModules { start_after, limit } => { + query_active_proposal_modules(deps, start_after, limit) + } + QueryMsg::ListSubDaos { start_after, limit } => { + query_list_sub_daos(deps, start_after, limit) + } + QueryMsg::DaoURI {} => query_dao_uri(deps), + } +} + +pub fn query_admin(deps: Deps) -> StdResult { + let admin = ADMIN.load(deps.storage)?; + to_binary(&admin) +} + +pub fn query_admin_nomination(deps: Deps) -> StdResult { + let nomination = NOMINATED_ADMIN.may_load(deps.storage)?; + to_binary(&AdminNominationResponse { nomination }) +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&config) +} + +pub fn query_voting_module(deps: Deps) -> StdResult { + let voting_module = VOTING_MODULE.load(deps.storage)?; + to_binary(&voting_module) +} + +pub fn query_proposal_modules( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + // This query is will run out of gas due to the size of the + // returned message before it runs out of compute so taking a + // limit here is still nice. As removes happen in constant time + // the contract is still recoverable if too many items end up in + // here. + // + // Further, as the `range` method on a map returns an iterator it + // is possible (though implementation dependent) that new keys are + // loaded on demand as the iterator is moved. Should this be the + // case we are only paying for what we need here. + // + // Even if this does lock up one can determine the existing + // proposal modules by looking at past transactions on chain. + to_binary(&paginate_map_values( + deps, + &PROPOSAL_MODULES, + start_after + .map(|s| deps.api.addr_validate(&s)) + .transpose()?, + limit, + cosmwasm_std::Order::Ascending, + )?) +} + +pub fn query_active_proposal_modules( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + // Note: this is not gas efficient as we need to potentially visit all modules in order to + // filter out the modules with active status. + let values = paginate_map_values( + deps, + &PROPOSAL_MODULES, + start_after + .map(|s| deps.api.addr_validate(&s)) + .transpose()?, + None, + cosmwasm_std::Order::Ascending, + )?; + + let limit = limit.unwrap_or(values.len() as u32); + + to_binary::>( + &values + .into_iter() + .filter(|module: &ProposalModule| module.status == ProposalModuleStatus::Enabled) + .take(limit as usize) + .collect(), + ) +} + +fn get_pause_info(deps: Deps, env: Env) -> StdResult { + Ok(match PAUSED.may_load(deps.storage)? { + Some(expiration) => { + if expiration.is_expired(&env.block) { + PauseInfoResponse::Unpaused {} + } else { + PauseInfoResponse::Paused { expiration } + } + } + None => PauseInfoResponse::Unpaused {}, + }) +} + +pub fn query_paused(deps: Deps, env: Env) -> StdResult { + to_binary(&get_pause_info(deps, env)?) +} + +pub fn query_dump_state(deps: Deps, env: Env) -> StdResult { + let admin = ADMIN.load(deps.storage)?; + let config = CONFIG.load(deps.storage)?; + let voting_module = VOTING_MODULE.load(deps.storage)?; + let proposal_modules = PROPOSAL_MODULES + .range(deps.storage, None, None, cosmwasm_std::Order::Ascending) + .map(|kv| Ok(kv?.1)) + .collect::>>()?; + let pause_info = get_pause_info(deps, env)?; + let version = get_contract_version(deps.storage)?; + let active_proposal_module_count = ACTIVE_PROPOSAL_MODULE_COUNT.load(deps.storage)?; + let total_proposal_module_count = TOTAL_PROPOSAL_MODULE_COUNT.load(deps.storage)?; + to_binary(&DumpStateResponse { + admin, + config, + version, + pause_info, + proposal_modules, + voting_module, + active_proposal_module_count, + total_proposal_module_count, + }) +} + +pub fn query_voting_power_at_height( + deps: Deps, + address: String, + height: Option, +) -> StdResult { + let voting_module = VOTING_MODULE.load(deps.storage)?; + let voting_power: voting::VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( + voting_module, + &voting::Query::VotingPowerAtHeight { height, address }, + )?; + to_binary(&voting_power) +} + +pub fn query_total_power_at_height(deps: Deps, height: Option) -> StdResult { + let voting_module = VOTING_MODULE.load(deps.storage)?; + let total_power: voting::TotalPowerAtHeightResponse = deps + .querier + .query_wasm_smart(voting_module, &voting::Query::TotalPowerAtHeight { height })?; + to_binary(&total_power) +} + +pub fn query_get_item(deps: Deps, item: String) -> StdResult { + let item = ITEMS.may_load(deps.storage, item)?; + to_binary(&GetItemResponse { item }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +pub fn query_list_items( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + to_binary(&paginate_map( + deps, + &ITEMS, + start_after, + limit, + cosmwasm_std::Order::Descending, + )?) +} + +pub fn query_cw20_list( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + to_binary(&paginate_map_keys( + deps, + &CW20_LIST, + start_after + .map(|s| deps.api.addr_validate(&s)) + .transpose()?, + limit, + cosmwasm_std::Order::Descending, + )?) +} + +pub fn query_cw721_list( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + to_binary(&paginate_map_keys( + deps, + &CW721_LIST, + start_after + .map(|s| deps.api.addr_validate(&s)) + .transpose()?, + limit, + cosmwasm_std::Order::Descending, + )?) +} + +pub fn query_cw20_balances( + deps: Deps, + env: Env, + start_after: Option, + limit: Option, +) -> StdResult { + let addrs = paginate_map_keys( + deps, + &CW20_LIST, + start_after + .map(|a| deps.api.addr_validate(&a)) + .transpose()?, + limit, + cosmwasm_std::Order::Descending, + )?; + let balances = addrs + .into_iter() + .map(|addr| { + let balance: cw20::BalanceResponse = deps.querier.query_wasm_smart( + addr.clone(), + &cw20::Cw20QueryMsg::Balance { + address: env.contract.address.to_string(), + }, + )?; + Ok(Cw20BalanceResponse { + addr, + balance: balance.balance, + }) + }) + .collect::>>()?; + to_binary(&balances) +} + +pub fn query_list_sub_daos( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let start_at = start_after + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + + let subdaos = cw_paginate_storage::paginate_map( + deps, + &SUBDAO_LIST, + start_at.as_ref(), + limit, + cosmwasm_std::Order::Ascending, + )?; + + let subdaos: Vec = subdaos + .into_iter() + .map(|(address, charter)| SubDao { + addr: address.into_string(), + charter, + }) + .collect(); + + to_binary(&subdaos) +} + +pub fn query_dao_uri(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&DaoURIResponse { + dao_uri: config.dao_uri, + }) +} + +pub fn query_proposal_module_count(deps: Deps) -> StdResult { + to_binary(&ProposalModuleCountResponse { + active_proposal_module_count: ACTIVE_PROPOSAL_MODULE_COUNT.load(deps.storage)?, + total_proposal_module_count: TOTAL_PROPOSAL_MODULE_COUNT.load(deps.storage)?, + }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg) -> Result { + let ContractVersion { version, .. } = get_contract_version(deps.storage)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + match msg { + MigrateMsg::FromV1 { dao_uri, params } => { + // `CONTRACT_VERSION` here is from the data section of the + // blob we are migrating to. `version` is from storage. If + // the version in storage matches the version in the blob + // we are not upgrading. + if version == CONTRACT_VERSION { + return Err(ContractError::AlreadyMigrated {}); + } + + use cw_core_v1 as v1; + + let current_keys = v1::state::PROPOSAL_MODULES + .keys(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + + // All proposal modules are considered active in v1. + let module_count = &(current_keys.len() as u32); + TOTAL_PROPOSAL_MODULE_COUNT.save(deps.storage, module_count)?; + ACTIVE_PROPOSAL_MODULE_COUNT.save(deps.storage, module_count)?; + + // Update proposal modules to v2. + current_keys + .into_iter() + .enumerate() + .try_for_each::<_, StdResult<()>>(|(idx, address)| { + let prefix = derive_proposal_module_prefix(idx)?; + let proposal_module = &ProposalModule { + address: address.clone(), + status: ProposalModuleStatus::Enabled {}, + prefix, + }; + PROPOSAL_MODULES.save(deps.storage, address, proposal_module)?; + Ok(()) + })?; + + // Update config to have the V2 "dao_uri" field. + let v1_config = v1::state::CONFIG.load(deps.storage)?; + CONFIG.save( + deps.storage, + &Config { + name: v1_config.name, + description: v1_config.description, + image_url: v1_config.image_url, + automatically_add_cw20s: v1_config.automatically_add_cw20s, + automatically_add_cw721s: v1_config.automatically_add_cw721s, + dao_uri, + }, + )?; + + let response = if let Some(migrate_params) = params { + let msg = WasmMsg::Execute { + contract_addr: env.contract.address.to_string(), + msg: to_binary(&ExecuteMsg::UpdateProposalModules { + to_add: vec![ModuleInstantiateInfo { + code_id: migrate_params.migrator_code_id, + msg: to_binary(&migrate_params.params).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "migrator".to_string(), + }], + to_disable: vec![], + }) + .unwrap(), + funds: vec![], + }; + Response::default().add_message(msg) + } else { + Response::default() + }; + + Ok(response) + } + MigrateMsg::FromCompatible {} => Ok(Response::default()), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + PROPOSAL_MODULE_REPLY_ID => { + let res = parse_reply_instantiate_data(msg)?; + let prop_module_addr = deps.api.addr_validate(&res.contract_address)?; + let total_module_count = TOTAL_PROPOSAL_MODULE_COUNT.load(deps.storage)?; + + let prefix = derive_proposal_module_prefix(total_module_count as usize)?; + let prop_module = ProposalModule { + address: prop_module_addr.clone(), + status: ProposalModuleStatus::Enabled, + prefix, + }; + + PROPOSAL_MODULES.save(deps.storage, prop_module_addr, &prop_module)?; + + // Save active and total proposal module counts. + ACTIVE_PROPOSAL_MODULE_COUNT + .update::<_, StdError>(deps.storage, |count| Ok(count + 1))?; + TOTAL_PROPOSAL_MODULE_COUNT.save(deps.storage, &(total_module_count + 1))?; + + // Check for module instantiation callbacks + let callback_msgs = match res.data { + Some(data) => from_binary::(&data) + .map(|m| m.msgs) + .unwrap_or_else(|_| vec![]), + None => vec![], + }; + + Ok(Response::default() + .add_attribute("prop_module".to_string(), res.contract_address) + .add_messages(callback_msgs)) + } + + VOTE_MODULE_INSTANTIATE_REPLY_ID => { + let res = parse_reply_instantiate_data(msg)?; + let vote_module_addr = deps.api.addr_validate(&res.contract_address)?; + let current = VOTING_MODULE.may_load(deps.storage)?; + + // Make sure a bug in instantiation isn't causing us to + // make more than one voting module. + if current.is_some() { + return Err(ContractError::MultipleVotingModules {}); + } + + VOTING_MODULE.save(deps.storage, &vote_module_addr)?; + + // Check for module instantiation callbacks + let callback_msgs = match res.data { + Some(data) => from_binary::(&data) + .map(|m| m.msgs) + .unwrap_or_else(|_| vec![]), + None => vec![], + }; + + Ok(Response::default() + .add_attribute("voting_module", vote_module_addr) + .add_messages(callback_msgs)) + } + VOTE_MODULE_UPDATE_REPLY_ID => { + let res = parse_reply_instantiate_data(msg)?; + let vote_module_addr = deps.api.addr_validate(&res.contract_address)?; + + VOTING_MODULE.save(deps.storage, &vote_module_addr)?; + + Ok(Response::default().add_attribute("voting_module", vote_module_addr)) + } + _ => Err(ContractError::UnknownReplyID {}), + } +} + +pub(crate) fn derive_proposal_module_prefix(mut dividend: usize) -> StdResult { + dividend += 1; + // Pre-allocate string + let mut prefix = String::with_capacity(10); + loop { + let remainder = (dividend - 1) % 26; + dividend = (dividend - remainder) / 26; + let remainder_str = std::str::from_utf8(&[(remainder + 65) as u8])?.to_owned(); + prefix.push_str(&remainder_str); + if dividend == 0 { + break; + } + } + Ok(prefix.chars().rev().collect()) +} + +#[cfg(test)] +mod test { + use crate::contract::derive_proposal_module_prefix; + use std::collections::HashSet; + + #[test] + fn test_prefix_generation() { + assert_eq!("A", derive_proposal_module_prefix(0).unwrap()); + assert_eq!("B", derive_proposal_module_prefix(1).unwrap()); + assert_eq!("C", derive_proposal_module_prefix(2).unwrap()); + assert_eq!("AA", derive_proposal_module_prefix(26).unwrap()); + assert_eq!("AB", derive_proposal_module_prefix(27).unwrap()); + assert_eq!("BA", derive_proposal_module_prefix(26 * 2).unwrap()); + assert_eq!("BB", derive_proposal_module_prefix(26 * 2 + 1).unwrap()); + assert_eq!("CA", derive_proposal_module_prefix(26 * 3).unwrap()); + assert_eq!("JA", derive_proposal_module_prefix(26 * 10).unwrap()); + assert_eq!("YA", derive_proposal_module_prefix(26 * 25).unwrap()); + assert_eq!("ZA", derive_proposal_module_prefix(26 * 26).unwrap()); + assert_eq!("ZZ", derive_proposal_module_prefix(26 * 26 + 25).unwrap()); + assert_eq!("AAA", derive_proposal_module_prefix(26 * 26 + 26).unwrap()); + assert_eq!("YZA", derive_proposal_module_prefix(26 * 26 * 26).unwrap()); + assert_eq!("ZZ", derive_proposal_module_prefix(26 * 26 + 25).unwrap()); + } + + #[test] + fn test_prefixes_no_collisions() { + let mut seen = HashSet::::new(); + for i in 0..25 * 25 * 25 { + let prefix = derive_proposal_module_prefix(i).unwrap(); + if seen.contains(&prefix) { + panic!("already seen value") + } + seen.insert(prefix); + } + } +} diff --git a/contracts/dao-dao-core/src/error.rs b/contracts/dao-dao-core/src/error.rs new file mode 100644 index 000000000..9f199484f --- /dev/null +++ b/contracts/dao-dao-core/src/error.rs @@ -0,0 +1,59 @@ +use cosmwasm_std::{Addr, StdError}; +use cw_utils::ParseReplyError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + ParseReplyError(#[from] ParseReplyError), + + #[error("Unauthorized.")] + Unauthorized {}, + + #[error("The contract is paused.")] + Paused {}, + + #[error("No voting module provided.")] + NoVotingModule {}, + + #[error("Execution would result in no proposal modules being active.")] + NoActiveProposalModules {}, + + #[error("An unknown reply ID was received.")] + UnknownReplyID {}, + + #[error("Multiple voting modules during instantiation.")] + MultipleVotingModules {}, + + #[error("Unsigned integer overflow.")] + Overflow {}, + + #[error("Key is missing from storage")] + KeyMissing {}, + + #[error("No pending admin nomination.")] + NoAdminNomination {}, + + #[error( + "The pending admin nomination must be withdrawn before a new nomination can be created." + )] + PendingNomination {}, + + #[error("Proposal module with address ({address}) does not exist.")] + ProposalModuleDoesNotExist { address: Addr }, + + #[error("Proposal module with address ({address}) is already disabled.")] + ModuleAlreadyDisabled { address: Addr }, + + #[error("Proposal module with address is disabled and cannot execute messages.")] + ModuleDisabledCannotExecute { address: Addr }, + + #[error("Duplicate initial item: ({item})")] + DuplicateInitialItem { item: String }, + + #[error("Can not migrate. Current version is up to date.")] + AlreadyMigrated {}, +} diff --git a/contracts/dao-dao-core/src/lib.rs b/contracts/dao-dao-core/src/lib.rs new file mode 100644 index 000000000..20a9a57d7 --- /dev/null +++ b/contracts/dao-dao-core/src/lib.rs @@ -0,0 +1,10 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/dao-dao-core/src/msg.rs b/contracts/dao-dao-core/src/msg.rs new file mode 100644 index 000000000..99f0d21b4 --- /dev/null +++ b/contracts/dao-dao-core/src/msg.rs @@ -0,0 +1,240 @@ +use crate::state::Config; +use crate::{migrate_msg::MigrateParams, query::SubDao}; +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Empty}; +use cw_utils::Duration; +use dao_interface::ModuleInstantiateInfo; + +/// Information about an item to be stored in the items list. +#[cw_serde] +pub struct InitialItem { + /// The name of the item. + pub key: String, + /// The value the item will have at instantiation time. + pub value: String, +} + +#[cw_serde] +pub struct InstantiateMsg { + /// Optional Admin with the ability to execute DAO messages + /// directly. Useful for building SubDAOs controlled by a parent + /// DAO. If no admin is specified the contract is set as its own + /// admin so that the admin may be updated later by governance. + pub admin: Option, + /// The name of the core contract. + pub name: String, + /// A description of the core contract. + pub description: String, + /// An image URL to describe the core module contract. + pub image_url: Option, + + /// If true the contract will automatically add received cw20 + /// tokens to its treasury. + pub automatically_add_cw20s: bool, + /// If true the contract will automatically add received cw721 + /// tokens to its treasury. + pub automatically_add_cw721s: bool, + + /// Instantiate information for the core contract's voting + /// power module. + pub voting_module_instantiate_info: ModuleInstantiateInfo, + /// Instantiate information for the core contract's proposal modules. + /// NOTE: the pre-propose-base package depends on it being the case + /// that the core module instantiates its proposal module. + pub proposal_modules_instantiate_info: Vec, + + /// The items to instantiate this DAO with. Items are arbitrary + /// key-value pairs whose contents are controlled by governance. + /// + /// It is an error to provide two items with the same key. + pub initial_items: Option>, + /// Implements the DAO Star standard: + pub dao_uri: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Callable by the Admin, if one is configured. + /// Executes messages in order. + ExecuteAdminMsgs { msgs: Vec> }, + /// Callable by proposal modules. The DAO will execute the + /// messages in the hook in order. + ExecuteProposalHook { msgs: Vec> }, + /// Pauses the DAO for a set duration. + /// When paused the DAO is unable to execute proposals + Pause { duration: Duration }, + /// Executed when the contract receives a cw20 token. Depending on + /// the contract's configuration the contract will automatically + /// add the token to its treasury. + Receive(cw20::Cw20ReceiveMsg), + /// Executed when the contract receives a cw721 token. Depending + /// on the contract's configuration the contract will + /// automatically add the token to its treasury. + ReceiveNft(cw721::Cw721ReceiveMsg), + /// Removes an item from the governance contract's item map. + RemoveItem { key: String }, + /// Adds an item to the governance contract's item map. If the + /// item already exists the existing value is overridden. If the + /// item does not exist a new item is added. + SetItem { key: String, value: String }, + /// Callable by the admin of the contract. If ADMIN is None the + /// admin is set as the contract itself so that it may be updated + /// later by vote. If ADMIN is Some a new admin is proposed and + /// that new admin may become the admin by executing the + /// `AcceptAdminNomination` message. + /// + /// If there is already a pending admin nomination the + /// `WithdrawAdminNomination` message must be executed before a + /// new admin may be nominated. + NominateAdmin { admin: Option }, + /// Callable by a nominated admin. Admins are nominated via the + /// `NominateAdmin` message. Accepting a nomination will make the + /// nominated address the new admin. + /// + /// Requiring that the new admin accepts the nomination before + /// becoming the admin protects against a typo causing the admin + /// to change to an invalid address. + AcceptAdminNomination {}, + /// Callable by the current admin. Withdraws the current admin + /// nomination. + WithdrawAdminNomination {}, + /// Callable by the core contract. Replaces the current + /// governance contract config with the provided config. + UpdateConfig { config: Config }, + /// Updates the list of cw20 tokens this contract has registered. + UpdateCw20List { + to_add: Vec, + to_remove: Vec, + }, + /// Updates the list of cw721 tokens this contract has registered. + UpdateCw721List { + to_add: Vec, + to_remove: Vec, + }, + /// Updates the governance contract's governance modules. Module + /// instantiate info in `to_add` is used to create new modules and + /// install them. + UpdateProposalModules { + /// NOTE: the pre-propose-base package depends on it being the + /// case that the core module instantiates its proposal module. + to_add: Vec, + to_disable: Vec, + }, + /// Callable by the core contract. Replaces the current + /// voting module with a new one instantiated by the governance + /// contract. + UpdateVotingModule { module: ModuleInstantiateInfo }, + /// Update the core module to add/remove SubDAOs and their charters + UpdateSubDaos { + to_add: Vec, + to_remove: Vec, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Get's the DAO's admin. Returns `Addr`. + #[returns(cosmwasm_std::Addr)] + Admin {}, + /// Get's the currently nominated admin (if any). + #[returns(crate::query::AdminNominationResponse)] + AdminNomination {}, + /// Gets the contract's config. + #[returns(Config)] + Config {}, + /// Gets the token balance for each cw20 registered with the + /// contract. + #[returns(crate::query::Cw20BalanceResponse)] + Cw20Balances { + start_after: Option, + limit: Option, + }, + /// Lists the addresses of the cw20 tokens in this contract's + /// treasury. + #[returns(Vec)] + Cw20TokenList { + start_after: Option, + limit: Option, + }, + /// Lists the addresses of the cw721 tokens in this contract's + /// treasury. + #[returns(Vec)] + Cw721TokenList { + start_after: Option, + limit: Option, + }, + /// Dumps all of the core contract's state in a single + /// query. Useful for frontends as performance for queries is more + /// limited by network times than compute times. + #[returns(crate::query::DumpStateResponse)] + DumpState {}, + /// Gets the address associated with an item key. + #[returns(crate::query::GetItemResponse)] + GetItem { key: String }, + /// Lists all of the items associted with the contract. For + /// example, given the items `{ "group": "foo", "subdao": "bar"}` + /// this query would return `[("group", "foo"), ("subdao", + /// "bar")]`. + #[returns(Vec)] + ListItems { + start_after: Option, + limit: Option, + }, + /// Returns contract version info + #[returns(dao_interface::voting::InfoResponse)] + Info {}, + /// Gets all proposal modules associated with the + /// contract. + #[returns(Vec)] + ProposalModules { + start_after: Option, + limit: Option, + }, + /// Gets the active proposal modules associated with the + /// contract. + #[returns(Vec)] + ActiveProposalModules { + start_after: Option, + limit: Option, + }, + /// Gets the number of active and total proposal modules + /// registered with this module. + #[returns(crate::query::ProposalModuleCountResponse)] + ProposalModuleCount {}, + /// Returns information about if the contract is currently paused. + #[returns(crate::query::PauseInfoResponse)] + PauseInfo {}, + /// Gets the contract's voting module. + #[returns(cosmwasm_std::Addr)] + VotingModule {}, + /// Returns all SubDAOs with their charters in a vec. + /// start_after is bound exclusive and asks for a string address. + #[returns(Vec)] + ListSubDaos { + start_after: Option, + limit: Option, + }, + /// Implements the DAO Star standard: + #[returns(crate::query::DaoURIResponse)] + DaoURI {}, + /// Returns the voting power for an address at a given height. + #[returns(dao_interface::voting::VotingPowerAtHeightResponse)] + VotingPowerAtHeight { + address: String, + height: Option, + }, + /// Returns the total voting power at a given block height. + #[returns(dao_interface::voting::TotalPowerAtHeightResponse)] + TotalPowerAtHeight { height: Option }, +} + +#[allow(clippy::large_enum_variant)] +#[cw_serde] +pub enum MigrateMsg { + FromV1 { + dao_uri: Option, + params: Option, + }, + FromCompatible {}, +} diff --git a/contracts/dao-dao-core/src/state.rs b/contracts/dao-dao-core/src/state.rs new file mode 100644 index 000000000..cd9e6198d --- /dev/null +++ b/contracts/dao-dao-core/src/state.rs @@ -0,0 +1,55 @@ +use cosmwasm_std::{Addr, Empty}; +use cw_storage_plus::{Item, Map}; +use cw_utils::Expiration; +use dao_interface::state::{Config, ProposalModule}; + +/// The admin of the contract. Typically a DAO. The contract admin may +/// unilaterally execute messages on this contract. +/// +/// In cases where no admin is wanted the admin should be set to the +/// contract itself. This will happen by default if no admin is +/// specified in `NominateAdmin` and instantiate messages. +pub const ADMIN: Item = Item::new("admin"); + +/// A new admin that has been nominated by the current admin. The +/// nominated admin must accept the proposal before becoming the admin +/// themselves. +/// +/// NOTE: If no admin is currently nominated this will not have a +/// value set. To load this value, use +/// `NOMINATED_ADMIN.may_load(deps.storage)`. +pub const NOMINATED_ADMIN: Item = Item::new("nominated_admin"); + +/// The current configuration of the module. +pub const CONFIG: Item = Item::new("config_v2"); + +/// The time the DAO will unpause. Here be dragons: this is not set if +/// the DAO has never been paused. +pub const PAUSED: Item = Item::new("paused"); + +/// The voting module associated with this contract. +pub const VOTING_MODULE: Item = Item::new("voting_module"); + +/// The proposal modules associated with this contract. +/// When we change the data format of this map, we update the key (previously "proposal_modules") +/// to create a new namespace for the changed state. +pub const PROPOSAL_MODULES: Map = Map::new("proposal_modules_v2"); + +/// The count of active proposal modules associated with this contract. +pub const ACTIVE_PROPOSAL_MODULE_COUNT: Item = Item::new("active_proposal_module_count"); + +/// The count of total proposal modules associated with this contract. +pub const TOTAL_PROPOSAL_MODULE_COUNT: Item = Item::new("total_proposal_module_count"); + +// General purpose KV store for DAO associated state. +pub const ITEMS: Map = Map::new("items"); + +/// Set of cw20 tokens that have been registered with this contract's +/// treasury. +pub const CW20_LIST: Map = Map::new("cw20s"); +/// Set of cw721 tokens that have been registered with this contract's +/// treasury. +pub const CW721_LIST: Map = Map::new("cw721s"); + +/// List of SubDAOs associated to this DAO. Each SubDAO has an optional charter. +pub const SUBDAO_LIST: Map<&Addr, Option> = Map::new("sub_daos"); diff --git a/contracts/dao-dao-core/src/tests.rs b/contracts/dao-dao-core/src/tests.rs new file mode 100644 index 000000000..dac148175 --- /dev/null +++ b/contracts/dao-dao-core/src/tests.rs @@ -0,0 +1,3082 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ + from_slice, + testing::{mock_dependencies, mock_env}, + to_binary, Addr, CosmosMsg, Empty, Storage, Uint128, WasmMsg, +}; +use cw2::{set_contract_version, ContractVersion}; +use cw_multi_test::{App, Contract, ContractWrapper, Executor}; +use cw_storage_plus::{Item, Map}; +use cw_utils::{Duration, Expiration}; +use dao_interface::{ + msg::{ExecuteMsg, InitialItem, InstantiateMsg, MigrateMsg, QueryMsg}, + query::{ + AdminNominationResponse, Cw20BalanceResponse, DaoURIResponse, DumpStateResponse, + GetItemResponse, PauseInfoResponse, ProposalModuleCountResponse, SubDao, + }, + state::{Admin, Config, ModuleInstantiateInfo, ProposalModule, ProposalModuleStatus}, + voting::{InfoResponse, VotingPowerAtHeightResponse}, +}; + +use crate::{ + contract::{derive_proposal_module_prefix, migrate, CONTRACT_NAME, CONTRACT_VERSION}, + state::PROPOSAL_MODULES, + ContractError, +}; + +const CREATOR_ADDR: &str = "creator"; + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn cw721_contract() -> Box> { + let contract = ContractWrapper::new( + cw721_base::entry::execute, + cw721_base::entry::instantiate, + cw721_base::entry::query, + ); + Box::new(contract) +} + +fn sudo_proposal_contract() -> Box> { + let contract = ContractWrapper::new( + dao_proposal_sudo::contract::execute, + dao_proposal_sudo::contract::instantiate, + dao_proposal_sudo::contract::query, + ); + Box::new(contract) +} + +fn cw20_balances_voting() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw20_balance::contract::execute, + dao_voting_cw20_balance::contract::instantiate, + dao_voting_cw20_balance::contract::query, + ) + .with_reply(dao_voting_cw20_balance::contract::reply); + Box::new(contract) +} + +fn cw_core_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +fn v1_cw_core_contract() -> Box> { + use cw_core_v1::contract; + let contract = ContractWrapper::new(contract::execute, contract::instantiate, contract::query) + .with_reply(contract::reply) + .with_migrate(contract::migrate); + Box::new(contract) +} + +fn instantiate_gov(app: &mut App, code_id: u64, msg: InstantiateMsg) -> Addr { + app.instantiate_contract( + code_id, + Addr::unchecked(CREATOR_ADDR), + &msg, + &[], + "cw-governance", + None, + ) + .unwrap() +} + +fn test_instantiate_with_n_gov_modules(n: usize) { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let gov_id = app.store_code(cw_core_contract()); + + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![], + mint: None, + marketing: None, + }; + let instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: cw20_id, + msg: to_binary(&cw20_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: (0..n) + .map(|n| ModuleInstantiateInfo { + code_id: cw20_id, + msg: to_binary(&cw20_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: format!("governance module {n}"), + }) + .collect(), + initial_items: None, + }; + let gov_addr = instantiate_gov(&mut app, gov_id, instantiate); + + let state: DumpStateResponse = app + .wrap() + .query_wasm_smart(gov_addr, &QueryMsg::DumpState {}) + .unwrap(); + + assert_eq!( + state.config, + Config { + dao_uri: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + } + ); + + assert_eq!(state.proposal_modules.len(), n); + + assert_eq!(state.active_proposal_module_count, n as u32); + assert_eq!(state.total_proposal_module_count, n as u32); +} + +#[test] +#[should_panic(expected = "Execution would result in no proposal modules being active.")] +fn test_instantiate_with_zero_gov_modules() { + test_instantiate_with_n_gov_modules(0) +} + +#[test] +fn test_valid_instantiate() { + let module_counts = [1, 2, 200]; + for count in module_counts { + test_instantiate_with_n_gov_modules(count) + } +} + +#[test] +#[should_panic(expected = "Error parsing into type cw20_base::msg::InstantiateMsg: Invalid type")] +fn test_instantiate_with_submessage_failure() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let gov_id = app.store_code(cw_core_contract()); + + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![], + mint: None, + marketing: None, + }; + + let mut governance_modules = (0..3) + .map(|n| ModuleInstantiateInfo { + code_id: cw20_id, + msg: to_binary(&cw20_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: format!("governance module {n}"), + }) + .collect::>(); + governance_modules.push(ModuleInstantiateInfo { + code_id: cw20_id, + msg: to_binary("bad").unwrap(), + admin: Some(Admin::CoreModule {}), + label: "I have a bad instantiate message".to_string(), + }); + governance_modules.push(ModuleInstantiateInfo { + code_id: cw20_id, + msg: to_binary(&cw20_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "Everybody knowing +that goodness is good +makes wickedness." + .to_string(), + }); + + let instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: cw20_id, + msg: to_binary(&cw20_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: governance_modules, + initial_items: None, + }; + instantiate_gov(&mut app, gov_id, instantiate); +} + +#[test] +fn test_update_config() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let gov_id = app.store_code(cw_core_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }], + initial_items: None, + }; + + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + let modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(modules.len(), 1); + + let expected_config = Config { + name: "Root DAO".to_string(), + description: "We love trees and sudo.".to_string(), + image_url: Some("https://moonphase.is/image.svg".to_string()), + automatically_add_cw20s: false, + automatically_add_cw721s: true, + dao_uri: Some("https://daostar.one/EIP".to_string()), + }; + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + modules[0].clone().address, + &dao_proposal_sudo::msg::ExecuteMsg::Execute { + msgs: vec![WasmMsg::Execute { + contract_addr: gov_addr.to_string(), + funds: vec![], + msg: to_binary(&ExecuteMsg::UpdateConfig { + config: expected_config.clone(), + }) + .unwrap(), + } + .into()], + }, + &[], + ) + .unwrap(); + + let config: Config = app + .wrap() + .query_wasm_smart(gov_addr.clone(), &QueryMsg::Config {}) + .unwrap(); + + assert_eq!(expected_config, config); + + let dao_uri: DaoURIResponse = app + .wrap() + .query_wasm_smart(gov_addr, &QueryMsg::DaoURI {}) + .unwrap(); + assert_eq!(dao_uri.dao_uri, expected_config.dao_uri); +} + +fn test_swap_governance(swaps: Vec<(u32, u32)>) { + let mut app = App::default(); + let propmod_id = app.store_code(sudo_proposal_contract()); + let core_id = app.store_code(cw_core_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: propmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: propmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: None, + }; + + let gov_addr = app + .instantiate_contract( + core_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + let modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(modules.len(), 1); + + let module_count = query_proposal_module_count(&app, &gov_addr); + assert_eq!( + module_count, + ProposalModuleCountResponse { + active_proposal_module_count: 1, + total_proposal_module_count: 1, + } + ); + + let (to_add, to_remove) = swaps + .iter() + .cloned() + .reduce(|(to_add, to_remove), (add, remove)| (to_add + add, to_remove + remove)) + .unwrap_or((0, 0)); + + for (add, remove) in swaps { + let start_modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + let start_modules_active: Vec = get_active_modules(&app, gov_addr.clone()); + + let to_add: Vec<_> = (0..add) + .map(|n| ModuleInstantiateInfo { + code_id: propmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: format!("governance module {n}"), + }) + .collect(); + + let to_disable: Vec<_> = start_modules_active + .iter() + .rev() + .take(remove as usize) + .map(|a| a.address.to_string()) + .collect(); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + start_modules_active[0].address.clone(), + &dao_proposal_sudo::msg::ExecuteMsg::Execute { + msgs: vec![WasmMsg::Execute { + contract_addr: gov_addr.to_string(), + funds: vec![], + msg: to_binary(&ExecuteMsg::UpdateProposalModules { to_add, to_disable }) + .unwrap(), + } + .into()], + }, + &[], + ) + .unwrap(); + + let finish_modules_active = get_active_modules(&app, gov_addr.clone()); + + assert_eq!( + finish_modules_active.len() as u32, + start_modules_active.len() as u32 + add - remove + ); + for module in start_modules + .clone() + .into_iter() + .rev() + .take(remove as usize) + { + assert!(!finish_modules_active.contains(&module)) + } + + let state: DumpStateResponse = app + .wrap() + .query_wasm_smart(gov_addr.clone(), &QueryMsg::DumpState {}) + .unwrap(); + + assert_eq!( + state.active_proposal_module_count, + finish_modules_active.len() as u32 + ); + + assert_eq!( + state.total_proposal_module_count, + start_modules.len() as u32 + add + ) + } + + let module_count = query_proposal_module_count(&app, &gov_addr); + assert_eq!( + module_count, + ProposalModuleCountResponse { + active_proposal_module_count: 1 + to_add - to_remove, + total_proposal_module_count: 1 + to_add, + } + ); +} + +#[test] +fn test_update_governance() { + test_swap_governance(vec![(1, 1), (5, 0), (0, 5), (0, 0)]); + test_swap_governance(vec![(1, 1), (1, 1), (1, 1), (1, 1)]) +} + +#[test] +fn test_add_then_remove_governance() { + test_swap_governance(vec![(1, 0), (0, 1)]) +} + +#[test] +#[should_panic(expected = "Execution would result in no proposal modules being active.")] +fn test_swap_governance_bad() { + test_swap_governance(vec![(1, 1), (0, 1)]) +} + +#[test] +fn test_removed_modules_can_not_execute() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let gov_id = app.store_code(cw_core_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: None, + }; + + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + let modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(modules.len(), 1); + + let start_module = modules.into_iter().next().unwrap(); + + let to_add = vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "new governance module".to_string(), + }]; + + let to_disable = vec![start_module.address.to_string()]; + + // Swap ourselves out. + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + start_module.address.clone(), + &dao_proposal_sudo::msg::ExecuteMsg::Execute { + msgs: vec![WasmMsg::Execute { + contract_addr: gov_addr.to_string(), + funds: vec![], + msg: to_binary(&ExecuteMsg::UpdateProposalModules { to_add, to_disable }).unwrap(), + } + .into()], + }, + &[], + ) + .unwrap(); + + let finish_modules_active: Vec = get_active_modules(&app, gov_addr.clone()); + + let new_proposal_module = finish_modules_active.into_iter().next().unwrap(); + + // Try to add a new module and remove the one we added + // earlier. This should fail as we have been removed. + let to_add = vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "new governance module".to_string(), + }]; + let to_disable = vec![new_proposal_module.address.to_string()]; + + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + start_module.address, + &dao_proposal_sudo::msg::ExecuteMsg::Execute { + msgs: vec![WasmMsg::Execute { + contract_addr: gov_addr.to_string(), + funds: vec![], + msg: to_binary(&ExecuteMsg::UpdateProposalModules { + to_add: to_add.clone(), + to_disable: to_disable.clone(), + }) + .unwrap(), + } + .into()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!( + err, + ContractError::ModuleDisabledCannotExecute { + address: _gov_address + } + )); + + // Check that the enabled query works. + let enabled_modules: Vec = app + .wrap() + .query_wasm_smart( + &gov_addr, + &QueryMsg::ActiveProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(enabled_modules, vec![new_proposal_module.clone()]); + + // The new proposal module should be able to perform actions. + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + new_proposal_module.address, + &dao_proposal_sudo::msg::ExecuteMsg::Execute { + msgs: vec![WasmMsg::Execute { + contract_addr: gov_addr.to_string(), + funds: vec![], + msg: to_binary(&ExecuteMsg::UpdateProposalModules { to_add, to_disable }).unwrap(), + } + .into()], + }, + &[], + ) + .unwrap(); +} + +#[test] +fn test_module_already_disabled() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let gov_id = app.store_code(cw_core_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: None, + }; + + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + let modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(modules.len(), 1); + + let start_module = modules.into_iter().next().unwrap(); + + let to_disable = vec![ + start_module.address.to_string(), + start_module.address.to_string(), + ]; + + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + start_module.address.clone(), + &dao_proposal_sudo::msg::ExecuteMsg::Execute { + msgs: vec![WasmMsg::Execute { + contract_addr: gov_addr.to_string(), + funds: vec![], + msg: to_binary(&ExecuteMsg::UpdateProposalModules { + to_add: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + to_disable, + }) + .unwrap(), + } + .into()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!( + err, + ContractError::ModuleAlreadyDisabled { + address: start_module.address + } + ) +} + +#[test] +fn test_swap_voting_module() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let gov_id = app.store_code(cw_core_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: None, + }; + + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + let voting_addr: Addr = app + .wrap() + .query_wasm_smart(gov_addr.clone(), &QueryMsg::VotingModule {}) + .unwrap(); + + let modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(modules.len(), 1); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + modules[0].address.clone(), + &dao_proposal_sudo::msg::ExecuteMsg::Execute { + msgs: vec![WasmMsg::Execute { + contract_addr: gov_addr.to_string(), + funds: vec![], + msg: to_binary(&ExecuteMsg::UpdateVotingModule { + module: ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + }) + .unwrap(), + } + .into()], + }, + &[], + ) + .unwrap(); + + let new_voting_addr: Addr = app + .wrap() + .query_wasm_smart(gov_addr, &QueryMsg::VotingModule {}) + .unwrap(); + + assert_ne!(new_voting_addr, voting_addr); +} + +fn test_unauthorized(app: &mut App, gov_addr: Addr, msg: ExecuteMsg) { + let err: ContractError = app + .execute_contract(Addr::unchecked(CREATOR_ADDR), gov_addr, &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::Unauthorized {}); +} + +#[test] +fn test_permissions() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let gov_id = app.store_code(cw_core_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + }; + + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + test_unauthorized( + &mut app, + gov_addr.clone(), + ExecuteMsg::UpdateVotingModule { + module: ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + }, + ); + + test_unauthorized( + &mut app, + gov_addr.clone(), + ExecuteMsg::UpdateProposalModules { + to_add: vec![], + to_disable: vec![], + }, + ); + + test_unauthorized( + &mut app, + gov_addr, + ExecuteMsg::UpdateConfig { + config: Config { + dao_uri: None, + name: "Evil config.".to_string(), + description: "👿".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + }, + }, + ); +} + +fn do_standard_instantiate(auto_add: bool, admin: Option) -> (Addr, App) { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let voting_id = app.store_code(cw20_balances_voting()); + let gov_id = app.store_code(cw_core_contract()); + let cw20_id = app.store_code(cw20_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + let voting_instantiate = dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![cw20::Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + }, + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: auto_add, + automatically_add_cw721s: auto_add, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: voting_id, + msg: to_binary(&voting_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: None, + }; + + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + (gov_addr, app) +} + +#[test] +fn test_admin_permissions() { + let (core_addr, mut app) = do_standard_instantiate(true, None); + + let start_height = app.block_info().height; + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(proposal_modules.len(), 1); + let proposal_module = proposal_modules.into_iter().next().unwrap(); + + // Random address can't call ExecuteAdminMsgs + let res = app.execute_contract( + Addr::unchecked("random"), + core_addr.clone(), + &ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ); + res.unwrap_err(); + + // Proposal mdoule can't call ExecuteAdminMsgs + let res = app.execute_contract( + proposal_module.address.clone(), + core_addr.clone(), + &ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ); + res.unwrap_err(); + + // Update Admin can't be called by non-admins + let res = app.execute_contract( + Addr::unchecked("rando"), + core_addr.clone(), + &ExecuteMsg::NominateAdmin { + admin: Some("rando".to_string()), + }, + &[], + ); + res.unwrap_err(); + + // Nominate admin can be called by core contract as no admin was + // specified so the admin defaulted to the core contract. + let res = app.execute_contract( + proposal_module.address.clone(), + core_addr.clone(), + &ExecuteMsg::ExecuteProposalHook { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::NominateAdmin { + admin: Some("meow".to_string()), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ); + res.unwrap(); + + // Instantiate new DAO with an admin + let (core_with_admin_addr, mut app) = + do_standard_instantiate(true, Some(Addr::unchecked("admin").to_string())); + + // Non admins still can't call ExecuteAdminMsgs + let res = app.execute_contract( + proposal_module.address, + core_with_admin_addr.clone(), + &ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![WasmMsg::Execute { + contract_addr: core_with_admin_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ); + res.unwrap_err(); + + // Admin can call ExecuteAdminMsgs, here an admin pasues the DAO + let res = app.execute_contract( + Addr::unchecked("admin"), + core_with_admin_addr.clone(), + &ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![WasmMsg::Execute { + contract_addr: core_with_admin_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ); + res.unwrap(); + + let paused: PauseInfoResponse = app + .wrap() + .query_wasm_smart(core_with_admin_addr.clone(), &QueryMsg::PauseInfo {}) + .unwrap(); + assert_eq!( + paused, + PauseInfoResponse::Paused { + expiration: Expiration::AtHeight(start_height + 10) + } + ); + + // DAO unpauses after 10 blocks + app.update_block(|block| block.height += 11); + + // Admin can nominate a new admin. + let res = app.execute_contract( + Addr::unchecked("admin"), + core_with_admin_addr.clone(), + &ExecuteMsg::NominateAdmin { + admin: Some("meow".to_string()), + }, + &[], + ); + res.unwrap(); + + let nomination: AdminNominationResponse = app + .wrap() + .query_wasm_smart(core_with_admin_addr.clone(), &QueryMsg::AdminNomination {}) + .unwrap(); + assert_eq!( + nomination, + AdminNominationResponse { + nomination: Some(Addr::unchecked("meow")) + } + ); + + // Check that admin has not yet been updated + let res: Addr = app + .wrap() + .query_wasm_smart(core_with_admin_addr.clone(), &QueryMsg::Admin {}) + .unwrap(); + assert_eq!(res, Addr::unchecked("admin")); + + // Only the nominated address may accept the nomination. + let err: ContractError = app + .execute_contract( + Addr::unchecked("random"), + core_with_admin_addr.clone(), + &ExecuteMsg::AcceptAdminNomination {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Accept the nomination. + app.execute_contract( + Addr::unchecked("meow"), + core_with_admin_addr.clone(), + &ExecuteMsg::AcceptAdminNomination {}, + &[], + ) + .unwrap(); + + // Check that admin has been updated + let res: Addr = app + .wrap() + .query_wasm_smart(core_with_admin_addr.clone(), &QueryMsg::Admin {}) + .unwrap(); + assert_eq!(res, Addr::unchecked("meow")); + + // Check that the pending admin has been cleared. + let nomination: AdminNominationResponse = app + .wrap() + .query_wasm_smart(core_with_admin_addr, &QueryMsg::AdminNomination {}) + .unwrap(); + assert_eq!(nomination, AdminNominationResponse { nomination: None }); +} + +#[test] +fn test_admin_nomination() { + let (core_addr, mut app) = do_standard_instantiate(true, Some("admin".to_string())); + + // Check that there is no pending nominations. + let nomination: AdminNominationResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::AdminNomination {}) + .unwrap(); + assert_eq!(nomination, AdminNominationResponse { nomination: None }); + + // Nominate a new admin. + app.execute_contract( + Addr::unchecked("admin"), + core_addr.clone(), + &ExecuteMsg::NominateAdmin { + admin: Some("ekez".to_string()), + }, + &[], + ) + .unwrap(); + + // Check that the nomination is in place. + let nomination: AdminNominationResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::AdminNomination {}) + .unwrap(); + assert_eq!( + nomination, + AdminNominationResponse { + nomination: Some(Addr::unchecked("ekez")) + } + ); + + // Non-admin can not withdraw. + let err: ContractError = app + .execute_contract( + Addr::unchecked("ekez"), + core_addr.clone(), + &ExecuteMsg::WithdrawAdminNomination {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Admin can withdraw. + app.execute_contract( + Addr::unchecked("admin"), + core_addr.clone(), + &ExecuteMsg::WithdrawAdminNomination {}, + &[], + ) + .unwrap(); + + // Check that the nomination is withdrawn. + let nomination: AdminNominationResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::AdminNomination {}) + .unwrap(); + assert_eq!(nomination, AdminNominationResponse { nomination: None }); + + // Can not withdraw if no nomination is pending. + let err: ContractError = app + .execute_contract( + Addr::unchecked("admin"), + core_addr.clone(), + &ExecuteMsg::WithdrawAdminNomination {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::NoAdminNomination {}); + + // Can not claim nomination b/c it has been withdrawn. + let err: ContractError = app + .execute_contract( + Addr::unchecked("ekez"), + core_addr.clone(), + &ExecuteMsg::AcceptAdminNomination {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::NoAdminNomination {}); + + // Nominate a new admin. + app.execute_contract( + Addr::unchecked("admin"), + core_addr.clone(), + &ExecuteMsg::NominateAdmin { + admin: Some("meow".to_string()), + }, + &[], + ) + .unwrap(); + + // A new nomination can not be created if there is already a + // pending nomination. + let err: ContractError = app + .execute_contract( + Addr::unchecked("admin"), + core_addr.clone(), + &ExecuteMsg::NominateAdmin { + admin: Some("arthur".to_string()), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::PendingNomination {}); + + // Only nominated admin may accept. + let err: ContractError = app + .execute_contract( + Addr::unchecked("ekez"), + core_addr.clone(), + &ExecuteMsg::AcceptAdminNomination {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + app.execute_contract( + Addr::unchecked("meow"), + core_addr.clone(), + &ExecuteMsg::AcceptAdminNomination {}, + &[], + ) + .unwrap(); + + // Check that meow is the new admin. + let admin: Addr = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::Admin {}) + .unwrap(); + assert_eq!(admin, Addr::unchecked("meow".to_string())); + + let start_height = app.block_info().height; + // Check that the new admin can do admin things and the old can not. + let err: ContractError = app + .execute_contract( + Addr::unchecked("admin"), + core_addr.clone(), + &ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + let res = app.execute_contract( + Addr::unchecked("meow"), + core_addr.clone(), + &ExecuteMsg::ExecuteAdminMsgs { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ); + res.unwrap(); + + let paused: PauseInfoResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::PauseInfo {}) + .unwrap(); + assert_eq!( + paused, + PauseInfoResponse::Paused { + expiration: Expiration::AtHeight(start_height + 10) + } + ); + + // DAO unpauses after 10 blocks + app.update_block(|block| block.height += 11); + + // Remove the admin. + app.execute_contract( + Addr::unchecked("meow"), + core_addr.clone(), + &ExecuteMsg::NominateAdmin { admin: None }, + &[], + ) + .unwrap(); + + // Check that this has not caused an admin to be nominated. + let nomination: AdminNominationResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::AdminNomination {}) + .unwrap(); + assert_eq!(nomination, AdminNominationResponse { nomination: None }); + + // Check that admin has been updated. As there was no admin + // nominated the admin should revert back to the contract address. + let res: Addr = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::Admin {}) + .unwrap(); + assert_eq!(res, core_addr); +} + +#[test] +fn test_passthrough_voting_queries() { + let (gov_addr, app) = do_standard_instantiate(true, None); + + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + gov_addr, + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::from(2u64), + height: app.block_info().height, + } + ); +} + +fn set_item(app: &mut App, gov_addr: Addr, key: String, value: String) { + app.execute_contract( + gov_addr.clone(), + gov_addr, + &ExecuteMsg::SetItem { key, value }, + &[], + ) + .unwrap(); +} + +fn remove_item(app: &mut App, gov_addr: Addr, key: String) { + app.execute_contract( + gov_addr.clone(), + gov_addr, + &ExecuteMsg::RemoveItem { key }, + &[], + ) + .unwrap(); +} + +fn get_item(app: &mut App, gov_addr: Addr, key: String) -> GetItemResponse { + app.wrap() + .query_wasm_smart(gov_addr, &QueryMsg::GetItem { key }) + .unwrap() +} + +fn list_items( + app: &mut App, + gov_addr: Addr, + start_at: Option, + limit: Option, +) -> Vec<(String, String)> { + app.wrap() + .query_wasm_smart( + gov_addr, + &QueryMsg::ListItems { + start_after: start_at, + limit, + }, + ) + .unwrap() +} + +#[test] +fn test_item_permissions() { + let (gov_addr, mut app) = do_standard_instantiate(true, None); + + let err: ContractError = app + .execute_contract( + Addr::unchecked("ekez"), + gov_addr.clone(), + &ExecuteMsg::SetItem { + key: "k".to_string(), + value: "v".to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + let err: ContractError = app + .execute_contract( + Addr::unchecked("ekez"), + gov_addr, + &ExecuteMsg::RemoveItem { + key: "k".to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); +} + +#[test] +fn test_add_remove_get() { + let (gov_addr, mut app) = do_standard_instantiate(true, None); + + let a = get_item(&mut app, gov_addr.clone(), "aaaaa".to_string()); + assert_eq!(a, GetItemResponse { item: None }); + + set_item( + &mut app, + gov_addr.clone(), + "aaaaakey".to_string(), + "aaaaaaddr".to_string(), + ); + let a = get_item(&mut app, gov_addr.clone(), "aaaaakey".to_string()); + assert_eq!( + a, + GetItemResponse { + item: Some("aaaaaaddr".to_string()) + } + ); + + remove_item(&mut app, gov_addr.clone(), "aaaaakey".to_string()); + let a = get_item(&mut app, gov_addr, "aaaaakey".to_string()); + assert_eq!(a, GetItemResponse { item: None }); +} + +#[test] +#[should_panic(expected = "Key is missing from storage")] +fn test_remove_missing_key() { + let (gov_addr, mut app) = do_standard_instantiate(true, None); + remove_item(&mut app, gov_addr, "b".to_string()) +} + +#[test] +fn test_list_items() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let voting_id = app.store_code(cw20_balances_voting()); + let gov_id = app.store_code(cw_core_contract()); + let cw20_id = app.store_code(cw20_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + let voting_instantiate = dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![cw20::Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + }, + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: voting_id, + msg: to_binary(&voting_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: None, + }; + + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + set_item( + &mut app, + gov_addr.clone(), + "fookey".to_string(), + "fooaddr".to_string(), + ); + set_item( + &mut app, + gov_addr.clone(), + "barkey".to_string(), + "baraddr".to_string(), + ); + set_item( + &mut app, + gov_addr.clone(), + "loremkey".to_string(), + "loremaddr".to_string(), + ); + set_item( + &mut app, + gov_addr.clone(), + "ipsumkey".to_string(), + "ipsumaddr".to_string(), + ); + + // Foo returned as we are only getting one item and items are in + // decending order. + let first_item = list_items(&mut app, gov_addr.clone(), None, Some(1)); + assert_eq!(first_item.len(), 1); + assert_eq!( + first_item[0], + ("loremkey".to_string(), "loremaddr".to_string()) + ); + + let no_items = list_items(&mut app, gov_addr.clone(), None, Some(0)); + assert_eq!(no_items.len(), 0); + + // Items are retreived in decending order so asking for foo with + // no limit ought to give us the barkey k/v. this will be the last item + // note: the paginate map bound is exclusive, so fookey will be starting point + let last_item = list_items(&mut app, gov_addr.clone(), Some("foo".to_string()), None); + assert_eq!(last_item.len(), 1); + assert_eq!(last_item[0], ("barkey".to_string(), "baraddr".to_string())); + + // Items are retreived in decending order so asking for ipsum with + // 4 limit ought to give us the fookey and barkey k/vs. + let after_foo_list = list_items(&mut app, gov_addr, Some("ipsum".to_string()), Some(4)); + assert_eq!(after_foo_list.len(), 2); + assert_eq!( + after_foo_list, + vec![ + ("fookey".to_string(), "fooaddr".to_string()), + ("barkey".to_string(), "baraddr".to_string()) + ] + ); +} + +#[test] +fn test_instantiate_with_items() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let voting_id = app.store_code(cw20_balances_voting()); + let gov_id = app.store_code(cw_core_contract()); + let cw20_id = app.store_code(cw20_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + let voting_instantiate = dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![cw20::Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + }, + }; + + let mut initial_items = vec![ + InitialItem { + key: "item0".to_string(), + value: "item0_value".to_string(), + }, + InitialItem { + key: "item1".to_string(), + value: "item1_value".to_string(), + }, + InitialItem { + key: "item0".to_string(), + value: "item0_value_override".to_string(), + }, + ]; + + let mut gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: voting_id, + msg: to_binary(&voting_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: Some(initial_items.clone()), + }; + + // Ensure duplicates are dissallowed. + let err: ContractError = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::DuplicateInitialItem { + item: "item0".to_string() + } + ); + + initial_items.pop(); + gov_instantiate.initial_items = Some(initial_items); + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + // Ensure initial items were added. + let items = list_items(&mut app, gov_addr.clone(), None, None); + assert_eq!(items.len(), 2); + + // Descending order, so item1 is first. + assert_eq!(items[1].0, "item0".to_string()); + let get_item0 = get_item(&mut app, gov_addr.clone(), "item0".to_string()); + assert_eq!( + get_item0, + GetItemResponse { + item: Some("item0_value".to_string()), + } + ); + + assert_eq!(items[0].0, "item1".to_string()); + let item1_value = get_item(&mut app, gov_addr, "item1".to_string()).item; + assert_eq!(item1_value, Some("item1_value".to_string())) +} + +#[test] +fn test_cw20_receive_auto_add() { + let (gov_addr, mut app) = do_standard_instantiate(true, None); + + let cw20_id = app.store_code(cw20_contract()); + let another_cw20 = app + .instantiate_contract( + cw20_id, + Addr::unchecked(CREATOR_ADDR), + &cw20_base::msg::InstantiateMsg { + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![], + mint: None, + marketing: None, + }, + &[], + "another-token", + None, + ) + .unwrap(); + + let voting_module: Addr = app + .wrap() + .query_wasm_smart(gov_addr.clone(), &QueryMsg::VotingModule {}) + .unwrap(); + let gov_token: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Check that the balances query works with no tokens. + let cw20_balances: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::Cw20Balances { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw20_balances, vec![]); + + // Send a gov token to the governance contract. + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + gov_token.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: gov_addr.to_string(), + amount: Uint128::new(1), + msg: to_binary(&"").unwrap(), + }, + &[], + ) + .unwrap(); + + let cw20_list: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::Cw20TokenList { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw20_list, vec![gov_token.clone()]); + + let cw20_balances: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::Cw20Balances { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + cw20_balances, + vec![Cw20BalanceResponse { + addr: gov_token.clone(), + balance: Uint128::new(1), + }] + ); + + // Test removing and adding some new ones. Invalid should fail. + let err: ContractError = app + .execute_contract( + Addr::unchecked(gov_addr.clone()), + gov_addr.clone(), + &ExecuteMsg::UpdateCw20List { + to_add: vec!["new".to_string()], + to_remove: vec![gov_token.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::Std(_))); + + // Test that non-DAO can not update the list. + let err: ContractError = app + .execute_contract( + Addr::unchecked("ekez"), + gov_addr.clone(), + &ExecuteMsg::UpdateCw20List { + to_add: vec![], + to_remove: vec![gov_token.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::Unauthorized {})); + + app.execute_contract( + Addr::unchecked(gov_addr.clone()), + gov_addr.clone(), + &ExecuteMsg::UpdateCw20List { + to_add: vec![another_cw20.to_string()], + to_remove: vec![gov_token.to_string()], + }, + &[], + ) + .unwrap(); + + let cw20_list: Vec = app + .wrap() + .query_wasm_smart( + gov_addr, + &QueryMsg::Cw20TokenList { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw20_list, vec![another_cw20]); +} + +#[test] +fn test_cw20_receive_no_auto_add() { + let (gov_addr, mut app) = do_standard_instantiate(false, None); + + let cw20_id = app.store_code(cw20_contract()); + let another_cw20 = app + .instantiate_contract( + cw20_id, + Addr::unchecked(CREATOR_ADDR), + &cw20_base::msg::InstantiateMsg { + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![], + mint: None, + marketing: None, + }, + &[], + "another-token", + None, + ) + .unwrap(); + + let voting_module: Addr = app + .wrap() + .query_wasm_smart(gov_addr.clone(), &QueryMsg::VotingModule {}) + .unwrap(); + let gov_token: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Send a gov token to the governance contract. Should not be + // added becasue auto add is turned off. + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + gov_token.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: gov_addr.to_string(), + amount: Uint128::new(1), + msg: to_binary(&"").unwrap(), + }, + &[], + ) + .unwrap(); + + let cw20_list: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::Cw20TokenList { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw20_list, Vec::::new()); + + app.execute_contract( + Addr::unchecked(gov_addr.clone()), + gov_addr.clone(), + &ExecuteMsg::UpdateCw20List { + to_add: vec![another_cw20.to_string(), gov_token.to_string()], + to_remove: vec!["ok to remove non existent".to_string()], + }, + &[], + ) + .unwrap(); + + let cw20_list: Vec = app + .wrap() + .query_wasm_smart( + gov_addr, + &QueryMsg::Cw20TokenList { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw20_list, vec![another_cw20, gov_token]); +} + +#[test] +fn test_cw721_receive() { + let (gov_addr, mut app) = do_standard_instantiate(true, None); + + let cw721_id = app.store_code(cw721_contract()); + + let cw721_addr = app + .instantiate_contract( + cw721_id, + Addr::unchecked(CREATOR_ADDR), + &cw721_base::msg::InstantiateMsg { + name: "ekez".to_string(), + symbol: "ekez".to_string(), + minter: CREATOR_ADDR.to_string(), + }, + &[], + "cw721", + None, + ) + .unwrap(); + + let another_cw721 = app + .instantiate_contract( + cw721_id, + Addr::unchecked(CREATOR_ADDR), + &cw721_base::msg::InstantiateMsg { + name: "ekez".to_string(), + symbol: "ekez".to_string(), + minter: CREATOR_ADDR.to_string(), + }, + &[], + "cw721", + None, + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + cw721_addr.clone(), + &cw721_base::msg::ExecuteMsg::, Empty>::Mint { + token_id: "ekez".to_string(), + owner: CREATOR_ADDR.to_string(), + token_uri: None, + extension: None, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + cw721_addr.clone(), + &cw721_base::msg::ExecuteMsg::, Empty>::SendNft { + contract: gov_addr.to_string(), + token_id: "ekez".to_string(), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap(); + + let cw721_list: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::Cw721TokenList { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw721_list, vec![cw721_addr.clone()]); + + // Try to add an invalid cw721. + let err: ContractError = app + .execute_contract( + Addr::unchecked(gov_addr.clone()), + gov_addr.clone(), + &ExecuteMsg::UpdateCw721List { + to_add: vec!["new".to_string(), cw721_addr.to_string()], + to_remove: vec![cw721_addr.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::Std(_))); + + // Test that non-DAO can not update the list. + let err: ContractError = app + .execute_contract( + Addr::unchecked("ekez"), + gov_addr.clone(), + &ExecuteMsg::UpdateCw721List { + to_add: vec![], + to_remove: vec![cw721_addr.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::Unauthorized {})); + + // Add a real cw721. + app.execute_contract( + Addr::unchecked(gov_addr.clone()), + gov_addr.clone(), + &ExecuteMsg::UpdateCw721List { + to_add: vec![another_cw721.to_string(), cw721_addr.to_string()], + to_remove: vec![cw721_addr.to_string()], + }, + &[], + ) + .unwrap(); + + let cw20_list: Vec = app + .wrap() + .query_wasm_smart( + gov_addr, + &QueryMsg::Cw721TokenList { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw20_list, vec![another_cw721]); +} + +#[test] +fn test_cw721_receive_no_auto_add() { + let (gov_addr, mut app) = do_standard_instantiate(false, None); + + let cw721_id = app.store_code(cw721_contract()); + + let cw721_addr = app + .instantiate_contract( + cw721_id, + Addr::unchecked(CREATOR_ADDR), + &cw721_base::msg::InstantiateMsg { + name: "ekez".to_string(), + symbol: "ekez".to_string(), + minter: CREATOR_ADDR.to_string(), + }, + &[], + "cw721", + None, + ) + .unwrap(); + + let another_cw721 = app + .instantiate_contract( + cw721_id, + Addr::unchecked(CREATOR_ADDR), + &cw721_base::msg::InstantiateMsg { + name: "ekez".to_string(), + symbol: "ekez".to_string(), + minter: CREATOR_ADDR.to_string(), + }, + &[], + "cw721", + None, + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + cw721_addr.clone(), + &cw721_base::msg::ExecuteMsg::, Empty>::Mint { + token_id: "ekez".to_string(), + owner: CREATOR_ADDR.to_string(), + token_uri: None, + extension: None, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + cw721_addr.clone(), + &cw721_base::msg::ExecuteMsg::, Empty>::SendNft { + contract: gov_addr.to_string(), + token_id: "ekez".to_string(), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap(); + + let cw721_list: Vec = app + .wrap() + .query_wasm_smart( + gov_addr.clone(), + &QueryMsg::Cw721TokenList { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw721_list, Vec::::new()); + + // Duplicates OK. Just adds one. + app.execute_contract( + Addr::unchecked(gov_addr.clone()), + gov_addr.clone(), + &ExecuteMsg::UpdateCw721List { + to_add: vec![ + another_cw721.to_string(), + cw721_addr.to_string(), + cw721_addr.to_string(), + ], + to_remove: vec![], + }, + &[], + ) + .unwrap(); + + let cw20_list: Vec = app + .wrap() + .query_wasm_smart( + gov_addr, + &QueryMsg::Cw721TokenList { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(cw20_list, vec![another_cw721, cw721_addr]); +} + +#[test] +fn test_pause() { + let (core_addr, mut app) = do_standard_instantiate(false, None); + + let start_height = app.block_info().height; + + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(proposal_modules.len(), 1); + let proposal_module = proposal_modules.into_iter().next().unwrap(); + + let paused: PauseInfoResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::PauseInfo {}) + .unwrap(); + assert_eq!(paused, PauseInfoResponse::Unpaused {}); + let all_state: DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::DumpState {}) + .unwrap(); + assert_eq!(all_state.pause_info, PauseInfoResponse::Unpaused {}); + + // DAO is not paused. Check that we can execute things. + // + // Tests intentionally use the core address to send these + // messsages to simulate a worst case scenerio where the core + // contract has a vulnerability. + app.execute_contract( + core_addr.clone(), + core_addr.clone(), + &ExecuteMsg::UpdateConfig { + config: Config { + dao_uri: None, + name: "The Empire Strikes Back".to_string(), + description: "haha lol we have pwned your DAO".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + }, + }, + &[], + ) + .unwrap(); + + // Oh no the DAO is under attack! Quick! Pause the DAO while we + // figure out what to do! + let err: ContractError = app + .execute_contract( + proposal_module.address.clone(), + core_addr.clone(), + &ExecuteMsg::Pause { + duration: Duration::Height(10), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // Only the DAO may call this on itself. Proposal modules must use + // the execute hook. + assert_eq!(err, ContractError::Unauthorized {}); + + app.execute_contract( + proposal_module.address.clone(), + core_addr.clone(), + &ExecuteMsg::ExecuteProposalHook { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap(); + + let paused: PauseInfoResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::PauseInfo {}) + .unwrap(); + assert_eq!( + paused, + PauseInfoResponse::Paused { + expiration: Expiration::AtHeight(start_height + 10) + } + ); + let all_state: DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::DumpState {}) + .unwrap(); + assert_eq!( + all_state.pause_info, + PauseInfoResponse::Paused { + expiration: Expiration::AtHeight(start_height + 10) + } + ); + + let err: ContractError = app + .execute_contract( + core_addr.clone(), + core_addr.clone(), + &ExecuteMsg::UpdateConfig { + config: Config { + dao_uri: None, + name: "The Empire Strikes Back Again".to_string(), + description: "haha lol we have pwned your DAO again".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::Paused { .. })); + + let err: ContractError = app + .execute_contract( + proposal_module.address.clone(), + core_addr.clone(), + &ExecuteMsg::ExecuteProposalHook { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::Paused { .. })); + + app.update_block(|block| block.height += 9); + + // Still not unpaused. + let err: ContractError = app + .execute_contract( + proposal_module.address.clone(), + core_addr.clone(), + &ExecuteMsg::ExecuteProposalHook { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::Paused { .. })); + + app.update_block(|block| block.height += 1); + + let paused: PauseInfoResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::PauseInfo {}) + .unwrap(); + assert_eq!(paused, PauseInfoResponse::Unpaused {}); + let all_state: DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::DumpState {}) + .unwrap(); + assert_eq!(all_state.pause_info, PauseInfoResponse::Unpaused {}); + + // Now its unpaused so we should be able to pause again. + app.execute_contract( + proposal_module.address, + core_addr.clone(), + &ExecuteMsg::ExecuteProposalHook { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&ExecuteMsg::Pause { + duration: Duration::Height(10), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap(); + + let paused: PauseInfoResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::PauseInfo {}) + .unwrap(); + assert_eq!( + paused, + PauseInfoResponse::Paused { + expiration: Expiration::AtHeight(start_height + 20) + } + ); + let all_state: DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &QueryMsg::DumpState {}) + .unwrap(); + assert_eq!( + all_state.pause_info, + PauseInfoResponse::Paused { + expiration: Expiration::AtHeight(start_height + 20) + } + ); +} + +#[test] +fn test_dump_state_proposal_modules() { + let (core_addr, app) = do_standard_instantiate(false, None); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(proposal_modules.len(), 1); + let proposal_module = proposal_modules.into_iter().next().unwrap(); + + let all_state: DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &QueryMsg::DumpState {}) + .unwrap(); + assert_eq!(all_state.pause_info, PauseInfoResponse::Unpaused {}); + assert_eq!(all_state.proposal_modules.len(), 1); + assert_eq!(all_state.proposal_modules[0], proposal_module); +} + +// Note that this isn't actually testing that we are migrating from the previous version since +// with multitest contract instantiation we can't manipulate storage to the previous version of state before invoking migrate. So if anything, +// this just tests the idempotency of migrate. +#[test] +fn test_migrate_from_compatible() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let voting_id = app.store_code(cw20_balances_voting()); + let gov_id = app.store_code(cw_core_contract()); + let cw20_id = app.store_code(cw20_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + let voting_instantiate = dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![cw20::Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + }, + }; + + // Instantiate the core module with an admin to do migrations. + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: voting_id, + msg: to_binary(&voting_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "governance module".to_string(), + }], + initial_items: None, + }; + + let core_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + Some(CREATOR_ADDR.to_string()), + ) + .unwrap(); + + let state: DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr.clone(), &QueryMsg::DumpState {}) + .unwrap(); + + app.execute( + Addr::unchecked(CREATOR_ADDR), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: core_addr.to_string(), + new_code_id: gov_id, + msg: to_binary(&MigrateMsg::FromCompatible {}).unwrap(), + }), + ) + .unwrap(); + + let new_state: DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &QueryMsg::DumpState {}) + .unwrap(); + + assert_eq!(new_state, state); +} + +#[test] +fn test_migrate_from_beta() { + use cw_core_v1 as v1; + + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let voting_id = app.store_code(cw20_balances_voting()); + let core_id = app.store_code(cw_core_contract()); + let v1_core_id = app.store_code(v1_cw_core_contract()); + let cw20_id = app.store_code(cw20_contract()); + + let proposal_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + let voting_instantiate = dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![cw20::Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + }, + }; + + // Instantiate the core module with an admin to do migrations. + let v1_core_instantiate = v1::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: v1::msg::ModuleInstantiateInfo { + code_id: voting_id, + msg: to_binary(&voting_instantiate).unwrap(), + admin: v1::msg::Admin::CoreContract {}, + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ + v1::msg::ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&proposal_instantiate).unwrap(), + admin: v1::msg::Admin::CoreContract {}, + label: "governance module 1".to_string(), + }, + v1::msg::ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&proposal_instantiate).unwrap(), + admin: v1::msg::Admin::CoreContract {}, + label: "governance module 2".to_string(), + }, + ], + initial_items: None, + }; + + let core_addr = app + .instantiate_contract( + v1_core_id, + Addr::unchecked(CREATOR_ADDR), + &v1_core_instantiate, + &[], + "cw-governance", + Some(CREATOR_ADDR.to_string()), + ) + .unwrap(); + + app.execute( + Addr::unchecked(CREATOR_ADDR), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: core_addr.to_string(), + new_code_id: core_id, + msg: to_binary(&MigrateMsg::FromV1 { + dao_uri: None, + params: None, + }) + .unwrap(), + }), + ) + .unwrap(); + + let new_state: DumpStateResponse = app + .wrap() + .query_wasm_smart(&core_addr, &QueryMsg::DumpState {}) + .unwrap(); + + let proposal_modules = new_state.proposal_modules; + assert_eq!(2, proposal_modules.len()); + for (idx, module) in proposal_modules.iter().enumerate() { + let prefix = derive_proposal_module_prefix(idx).unwrap(); + assert_eq!(prefix, module.prefix); + assert_eq!(ProposalModuleStatus::Enabled, module.status); + } + + // Check that we may not migrate more than once. + let err: ContractError = app + .execute( + Addr::unchecked(CREATOR_ADDR), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: core_addr.to_string(), + new_code_id: core_id, + msg: to_binary(&MigrateMsg::FromV1 { + dao_uri: None, + params: None, + }) + .unwrap(), + }), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::AlreadyMigrated {}) +} + +#[test] +fn test_migrate_mock() { + let mut deps = mock_dependencies(); + let dao_uri: String = "/dao/uri".to_string(); + let msg = MigrateMsg::FromV1 { + dao_uri: Some(dao_uri.clone()), + params: None, + }; + let env = mock_env(); + + // Set starting version to v1. + set_contract_version(&mut deps.storage, CONTRACT_NAME, "0.1.0").unwrap(); + + // Write to storage in old proposal module format + let proposal_modules_key = Addr::unchecked("addr"); + let old_map: Map = Map::new("proposal_modules"); + let path = old_map.key(proposal_modules_key.clone()); + deps.storage.set(&path, &to_binary(&Empty {}).unwrap()); + + // Write to storage in old config format + #[cw_serde] + struct V1Config { + pub name: String, + pub description: String, + pub image_url: Option, + pub automatically_add_cw20s: bool, + pub automatically_add_cw721s: bool, + } + + let v1_config = V1Config { + name: "core dao".to_string(), + description: "a dao".to_string(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + }; + + let config_item: Item = Item::new("config"); + config_item.save(&mut deps.storage, &v1_config).unwrap(); + + // Migrate to v2 + migrate(deps.as_mut(), env, msg).unwrap(); + + let new_path = PROPOSAL_MODULES.key(proposal_modules_key); + let prop_module_bytes = deps.storage.get(&new_path).unwrap(); + let module: ProposalModule = from_slice(&prop_module_bytes).unwrap(); + assert_eq!(module.address, Addr::unchecked("addr")); + assert_eq!(module.prefix, derive_proposal_module_prefix(0).unwrap()); + assert_eq!(module.status, ProposalModuleStatus::Enabled {}); + + let v2_config_item: Item = Item::new("config_v2"); + let v2_config = v2_config_item.load(&deps.storage).unwrap(); + assert_eq!(v2_config.dao_uri, Some(dao_uri)); + assert_eq!(v2_config.name, v1_config.name); + assert_eq!(v2_config.description, v1_config.description); + assert_eq!(v2_config.image_url, v1_config.image_url); + assert_eq!( + v2_config.automatically_add_cw20s, + v1_config.automatically_add_cw20s + ); + assert_eq!( + v2_config.automatically_add_cw721s, + v1_config.automatically_add_cw721s + ) +} + +#[test] +fn test_execute_stargate_msg() { + let (core_addr, mut app) = do_standard_instantiate(true, None); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(proposal_modules.len(), 1); + let proposal_module = proposal_modules.into_iter().next().unwrap(); + + let res = app.execute_contract( + proposal_module.address, + core_addr, + &ExecuteMsg::ExecuteProposalHook { + msgs: vec![CosmosMsg::Stargate { + type_url: "foo_type".to_string(), + value: to_binary("foo_bin").unwrap(), + }], + }, + &[], + ); + // TODO: Once cw-multi-test supports executing stargate/ibc messages we can change this test assert + assert!(res.is_err()); +} + +#[test] +fn test_module_prefixes() { + let mut app = App::default(); + let govmod_id = app.store_code(sudo_proposal_contract()); + let gov_id = app.store_code(cw_core_contract()); + + let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { + root: CREATOR_ADDR.to_string(), + }; + + let gov_instantiate = InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ + ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "proposal module 1".to_string(), + }, + ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "proposal module 2".to_string(), + }, + ModuleInstantiateInfo { + code_id: govmod_id, + msg: to_binary(&govmod_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "proposal module 2".to_string(), + }, + ], + initial_items: None, + }; + + let gov_addr = app + .instantiate_contract( + gov_id, + Addr::unchecked(CREATOR_ADDR), + &gov_instantiate, + &[], + "cw-governance", + None, + ) + .unwrap(); + + let modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr, + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(modules.len(), 3); + + let module_1 = &modules[0]; + assert_eq!(module_1.status, ProposalModuleStatus::Enabled {}); + assert_eq!(module_1.prefix, "A"); + assert_eq!(&module_1.address, &modules[0].address); + + let module_2 = &modules[1]; + assert_eq!(module_2.status, ProposalModuleStatus::Enabled {}); + assert_eq!(module_2.prefix, "B"); + assert_eq!(&module_2.address, &modules[1].address); + + let module_3 = &modules[2]; + assert_eq!(module_3.status, ProposalModuleStatus::Enabled {}); + assert_eq!(module_3.prefix, "C"); + assert_eq!(&module_3.address, &modules[2].address); +} + +fn get_active_modules(app: &App, gov_addr: Addr) -> Vec { + let modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr, + &QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + modules + .into_iter() + .filter(|module: &ProposalModule| module.status == ProposalModuleStatus::Enabled) + .collect() +} + +fn query_proposal_module_count(app: &App, core_addr: &Addr) -> ProposalModuleCountResponse { + app.wrap() + .query_wasm_smart(core_addr, &QueryMsg::ProposalModuleCount {}) + .unwrap() +} + +#[test] +fn test_add_remove_subdaos() { + let (core_addr, mut app) = do_standard_instantiate(false, None); + + test_unauthorized( + &mut app, + core_addr.clone(), + ExecuteMsg::UpdateSubDaos { + to_add: vec![], + to_remove: vec![], + }, + ); + + let to_add: Vec = vec![ + SubDao { + addr: "subdao001".to_string(), + charter: None, + }, + SubDao { + addr: "subdao002".to_string(), + charter: Some("cool charter bro".to_string()), + }, + SubDao { + addr: "subdao005".to_string(), + charter: None, + }, + SubDao { + addr: "subdao007".to_string(), + charter: None, + }, + ]; + let to_remove: Vec = vec![]; + + app.execute_contract( + Addr::unchecked(core_addr.clone()), + core_addr.clone(), + &ExecuteMsg::UpdateSubDaos { to_add, to_remove }, + &[], + ) + .unwrap(); + + let res: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &QueryMsg::ListSubDaos { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(res.len(), 4); + + let to_remove: Vec = vec!["subdao005".to_string()]; + + app.execute_contract( + Addr::unchecked(core_addr.clone()), + core_addr.clone(), + &ExecuteMsg::UpdateSubDaos { + to_add: vec![], + to_remove, + }, + &[], + ) + .unwrap(); + + let res: Vec = app + .wrap() + .query_wasm_smart( + core_addr, + &QueryMsg::ListSubDaos { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(res.len(), 3); + + let test_res: SubDao = SubDao { + addr: "subdao002".to_string(), + charter: Some("cool charter bro".to_string()), + }; + + assert_eq!(res[1], test_res); + + let full_result_set: Vec = vec![ + SubDao { + addr: "subdao001".to_string(), + charter: None, + }, + SubDao { + addr: "subdao002".to_string(), + charter: Some("cool charter bro".to_string()), + }, + SubDao { + addr: "subdao007".to_string(), + charter: None, + }, + ]; + + assert_eq!(res, full_result_set); +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").unwrap(); + migrate(deps.as_mut(), mock_env(), MigrateMsg::FromCompatible {}).unwrap(); + let version = cw2::get_contract_version(&deps.storage).unwrap(); + assert_eq!(version.version, CONTRACT_VERSION); + assert_eq!(version.contract, CONTRACT_NAME); +} + +#[test] +fn test_query_info() { + let (core_addr, app) = do_standard_instantiate(true, None); + let res: InfoResponse = app + .wrap() + .query_wasm_smart(core_addr, &QueryMsg::Info {}) + .unwrap(); + assert_eq!( + res, + InfoResponse { + info: ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string() + } + } + ) +} diff --git a/contracts/external/cw-admin-factory/.cargo/config b/contracts/external/cw-admin-factory/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/external/cw-admin-factory/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/external/cw-admin-factory/Cargo.toml b/contracts/external/cw-admin-factory/Cargo.toml new file mode 100644 index 000000000..b22399df8 --- /dev/null +++ b/contracts/external/cw-admin-factory/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name ="cw-admin-factory" +authors = ["Jake Hartnell", "blue-note", "ekez "] +description = "A CosmWasm factory contract for instantiating a contract as its own admin." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-storage = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +thiserror = { workspace = true } +cw-utils = { workspace = true } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } +cw-multi-test = { workspace = true } +dao-dao-core = { workspace = true, features = ["library"] } +dao-interface = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } diff --git a/contracts/external/cw-admin-factory/README.md b/contracts/external/cw-admin-factory/README.md new file mode 100644 index 000000000..786329727 --- /dev/null +++ b/contracts/external/cw-admin-factory/README.md @@ -0,0 +1,11 @@ +# cw-admin-factory + +Serves as a factory that instantiates contracts and sets them as their +own wasm admins. + +Useful for allowing contracts (e.g. DAOs) to migrate themselves. + +Example instantiation flow: + +![](https://bafkreibqsrdnht5chc5mdzbb6pgiyqfjke3yvukvjrokyefwwbl3k3iwaa.ipfs.nftstorage.link) + diff --git a/contracts/external/cw-admin-factory/examples/schema.rs b/contracts/external/cw-admin-factory/examples/schema.rs new file mode 100644 index 000000000..f4e1c2919 --- /dev/null +++ b/contracts/external/cw-admin-factory/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use cw_admin_factory::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/external/cw-admin-factory/schema/cw-admin-factory.json b/contracts/external/cw-admin-factory/schema/cw-admin-factory.json new file mode 100644 index 000000000..67c92d629 --- /dev/null +++ b/contracts/external/cw-admin-factory/schema/cw-admin-factory.json @@ -0,0 +1,69 @@ +{ + "contract_name": "cw-admin-factory", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Instantiates the target contract with the provided instantiate message and code id and updates the contract's admin to be itself.", + "type": "object", + "required": [ + "instantiate_contract_with_self_admin" + ], + "properties": { + "instantiate_contract_with_self_admin": { + "type": "object", + "required": [ + "code_id", + "instantiate_msg", + "label" + ], + "properties": { + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "instantiate_msg": { + "$ref": "#/definitions/Binary" + }, + "label": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "type": "string", + "enum": [] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": {} +} diff --git a/contracts/external/cw-admin-factory/src/contract.rs b/contracts/external/cw-admin-factory/src/contract.rs new file mode 100644 index 000000000..f1f9b31df --- /dev/null +++ b/contracts/external/cw-admin-factory/src/contract.rs @@ -0,0 +1,98 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + Binary, Deps, DepsMut, Env, MessageInfo, Reply, Response, StdResult, SubMsg, WasmMsg, +}; + +use cw2::set_contract_version; +use cw_utils::parse_reply_instantiate_data; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:cw-admin-factory"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const INSTANTIATE_CONTRACT_REPLY_ID: u64 = 0; + +#[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)?; + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("creator", info.sender)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + _deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::InstantiateContractWithSelfAdmin { + instantiate_msg: msg, + code_id, + label, + } => instantiate_contract(env, info, msg, code_id, label), + } +} + +pub fn instantiate_contract( + env: Env, + info: MessageInfo, + instantiate_msg: Binary, + code_id: u64, + label: String, +) -> Result { + // Instantiate the specified contract with factory as the admin. + let instantiate = WasmMsg::Instantiate { + admin: Some(env.contract.address.to_string()), + code_id, + msg: instantiate_msg, + funds: info.funds, + label, + }; + + let msg = SubMsg::reply_on_success(instantiate, INSTANTIATE_CONTRACT_REPLY_ID); + Ok(Response::default() + .add_attribute("action", "instantiate_cw_core") + .add_submessage(msg)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(_deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg {} +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + INSTANTIATE_CONTRACT_REPLY_ID => { + let res = parse_reply_instantiate_data(msg)?; + let contract_addr = deps.api.addr_validate(&res.contract_address)?; + // Make the contract its own admin. + let msg = WasmMsg::UpdateAdmin { + contract_addr: contract_addr.to_string(), + admin: contract_addr.to_string(), + }; + + Ok(Response::default() + .add_attribute("set contract admin as itself", contract_addr) + .add_message(msg)) + } + _ => Err(ContractError::UnknownReplyID {}), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/external/cw-admin-factory/src/error.rs b/contracts/external/cw-admin-factory/src/error.rs new file mode 100644 index 000000000..56c764778 --- /dev/null +++ b/contracts/external/cw-admin-factory/src/error.rs @@ -0,0 +1,18 @@ +use cosmwasm_std::StdError; +use cw_utils::ParseReplyError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("{0}")] + ParseReplyError(#[from] ParseReplyError), + + #[error("An unknown reply ID was received.")] + UnknownReplyID {}, +} diff --git a/contracts/external/cw-admin-factory/src/lib.rs b/contracts/external/cw-admin-factory/src/lib.rs new file mode 100644 index 000000000..6902586b6 --- /dev/null +++ b/contracts/external/cw-admin-factory/src/lib.rs @@ -0,0 +1,10 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/external/cw-admin-factory/src/msg.rs b/contracts/external/cw-admin-factory/src/msg.rs new file mode 100644 index 000000000..1cc0d4258 --- /dev/null +++ b/contracts/external/cw-admin-factory/src/msg.rs @@ -0,0 +1,23 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Binary; + +#[cw_serde] +pub struct InstantiateMsg {} + +#[cw_serde] +pub enum ExecuteMsg { + /// Instantiates the target contract with the provided instantiate message and code id and + /// updates the contract's admin to be itself. + InstantiateContractWithSelfAdmin { + instantiate_msg: Binary, + code_id: u64, + label: String, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg {} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/external/cw-admin-factory/src/tests.rs b/contracts/external/cw-admin-factory/src/tests.rs new file mode 100644 index 000000000..226d1f1ef --- /dev/null +++ b/contracts/external/cw-admin-factory/src/tests.rs @@ -0,0 +1,165 @@ +use std::vec; + +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env, mock_info}, + to_binary, Addr, Binary, Empty, Reply, SubMsg, SubMsgResponse, SubMsgResult, WasmMsg, +}; + +use cw_multi_test::{App, AppResponse, Contract, ContractWrapper, Executor}; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; + +use crate::{ + contract::instantiate, + contract::{migrate, reply, CONTRACT_NAME, CONTRACT_VERSION, INSTANTIATE_CONTRACT_REPLY_ID}, + msg::{ExecuteMsg, InstantiateMsg, MigrateMsg}, +}; + +fn factory_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply); + Box::new(contract) +} + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn cw_core_contract() -> Box> { + let contract = ContractWrapper::new( + dao_dao_core::contract::execute, + dao_dao_core::contract::instantiate, + dao_dao_core::contract::query, + ) + .with_reply(dao_dao_core::contract::reply) + .with_migrate(dao_dao_core::contract::migrate); + Box::new(contract) +} + +#[test] +pub fn test_set_admin() { + let mut app = App::default(); + let code_id = app.store_code(factory_contract()); + let cw20_code_id = app.store_code(cw20_contract()); + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![], + mint: None, + marketing: None, + }; + + let instantiate = InstantiateMsg {}; + let factory_addr = app + .instantiate_contract( + code_id, + Addr::unchecked("CREATOR"), + &instantiate, + &[], + "cw-admin-factory", + None, + ) + .unwrap(); + + // Instantiate core contract using factory. + let cw_core_code_id = app.store_code(cw_core_contract()); + let instantiate_core = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs.".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: cw20_code_id, + msg: to_binary(&cw20_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ + ModuleInstantiateInfo { + code_id: cw20_code_id, + msg: to_binary(&cw20_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "prop module".to_string(), + }, + ModuleInstantiateInfo { + code_id: cw20_code_id, + msg: to_binary(&cw20_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "prop module 2".to_string(), + }, + ], + initial_items: None, + }; + + let res: AppResponse = app + .execute_contract( + Addr::unchecked("CREATOR"), + factory_addr, + &ExecuteMsg::InstantiateContractWithSelfAdmin { + instantiate_msg: to_binary(&instantiate_core).unwrap(), + code_id: cw_core_code_id, + label: "my contract".to_string(), + }, + &[], + ) + .unwrap(); + + // Get the core address from the instantiate event + let instantiate_event = &res.events[2]; + assert_eq!(instantiate_event.ty, "instantiate"); + let core_addr = instantiate_event.attributes[0].value.clone(); + + // Check that admin of core address is itself + let contract_info = app.wrap().query_wasm_contract_info(&core_addr).unwrap(); + assert_eq!(contract_info.admin, Some(core_addr)) +} + +#[test] +pub fn test_set_admin_mock() { + let mut deps = mock_dependencies(); + // Instantiate factory contract + let instantiate_msg = InstantiateMsg {}; + let info = mock_info("creator", &[]); + let env = mock_env(); + instantiate(deps.as_mut(), env.clone(), info, instantiate_msg).unwrap(); + let bytes = vec![10, 9, 99, 111, 110, 116, 114, 97, 99, 116, 50]; + let reply_msg: Reply = Reply { + id: INSTANTIATE_CONTRACT_REPLY_ID, + result: SubMsgResult::Ok(SubMsgResponse { + events: vec![], + data: (Some(Binary(bytes))), + }), + }; + + let res = reply(deps.as_mut(), env, reply_msg).unwrap(); + assert_eq!(res.attributes.len(), 1); + assert_eq!( + res.messages[0], + SubMsg::new(WasmMsg::UpdateAdmin { + contract_addr: "contract2".to_string(), + admin: "contract2".to_string() + }) + ) +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").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); +} diff --git a/contracts/external/cw-fund-distributor/.cargo/config b/contracts/external/cw-fund-distributor/.cargo/config new file mode 100644 index 000000000..5ea56d6f3 --- /dev/null +++ b/contracts/external/cw-fund-distributor/.cargo/config @@ -0,0 +1,5 @@ +[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.toml b/contracts/external/cw-fund-distributor/Cargo.toml new file mode 100644 index 000000000..558478ae3 --- /dev/null +++ b/contracts/external/cw-fund-distributor/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "cw-fund-distributor" +authors = ["bekauz "] +description = "A CosmWasm contract for distributing funds to DAO members based on voting power." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = "0.1.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +thiserror = { workspace = true } +cw-utils = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +cw20-stake = { workspace = true, features = ["library"] } +dao-interface = { workspace = true } +cw-paginate-storage = { workspace = true } + +[dev-dependencies] +dao-dao-core = { workspace = true, features = ["library"] } +cw-multi-test = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } diff --git a/contracts/external/cw-fund-distributor/README.md b/contracts/external/cw-fund-distributor/README.md new file mode 100644 index 000000000..1df4f15bd --- /dev/null +++ b/contracts/external/cw-fund-distributor/README.md @@ -0,0 +1,37 @@ +# cw-fund-distributor + +This contract is meant to facilitate fund distribution +proportional to the amount of voting power members have +at a given block height. + +Possible use cases may involve: +- Dissolving a DAO and distributing its treasury to members prior to shutting down +- Distributing funds among DAO members +- Funding subDAOs + +## Funding Period + +Contract is instantiated with a `funding_period` - a time duration that should suffice +to move the funds to be distributed into the distributor contract. + +Funding the contract can only happen during this period. +No claims can happen during this period. + +## Claiming/Distribution Period + +After the `funding_period` expires, the funds held by distributor contract become +available for claims. + +Funding the contract is no longer possible at this point. + +## Fund redistribution + +Considering it is more than likely that not every user would claim its allocation, +it is possible to redistribute the unclaimed funds. + +Only the `cw_admin` can call the method. + +The redistribution method finds all the claims that have been performed +and subtracts the amounts from the initially funded balance. The respective +allocation ratios for each DAO member remain the same; any previous claims +are cleared. diff --git a/contracts/external/cw-fund-distributor/examples/schema.rs b/contracts/external/cw-fund-distributor/examples/schema.rs new file mode 100644 index 000000000..6099e7a9e --- /dev/null +++ b/contracts/external/cw-fund-distributor/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_voting_cw20_staked::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json b/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json new file mode 100644 index 000000000..19a0541ca --- /dev/null +++ b/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json @@ -0,0 +1,849 @@ +{ + "contract_name": "cw-fund-distributor", + "contract_version": "0.1.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "token_info" + ], + "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" + } + ] + }, + "token_info": { + "$ref": "#/definitions/TokenInfo" + } + }, + "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 + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Cw20Coin": { + "type": "object", + "required": [ + "address", + "amount" + ], + "properties": { + "address": { + "type": "string" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "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 + } + ] + }, + "EmbeddedLogo": { + "description": "This is used to store the logo on the blockchain in an accepted format. Enforce maximum size of 5KB on all variants.", + "oneOf": [ + { + "description": "Store the Logo as an SVG file. The content must conform to the spec at https://en.wikipedia.org/wiki/Scalable_Vector_Graphics (The contract should do some light-weight sanity-check validation)", + "type": "object", + "required": [ + "svg" + ], + "properties": { + "svg": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + }, + { + "description": "Store the Logo as a PNG file. This will likely only support up to 64x64 or so within the 5KB limit.", + "type": "object", + "required": [ + "png" + ], + "properties": { + "png": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + } + ] + }, + "InstantiateMarketingInfo": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/Logo" + }, + { + "type": "null" + } + ] + }, + "marketing": { + "type": [ + "string", + "null" + ] + }, + "project": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "Logo": { + "description": "This is used for uploading logo data, or setting it in InstantiateData", + "oneOf": [ + { + "description": "A reference to an externally hosted logo. Must be a valid HTTP or HTTPS URL.", + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "Logo content stored on the blockchain. Enforce maximum size of 5KB on all variants", + "type": "object", + "required": [ + "embedded" + ], + "properties": { + "embedded": { + "$ref": "#/definitions/EmbeddedLogo" + } + }, + "additionalProperties": false + } + ] + }, + "StakingInfo": { + "description": "Information about the staking contract to be used with this voting module.", + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "staking_contract_address" + ], + "properties": { + "staking_contract_address": { + "description": "Address of an already instantiated staking contract.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "type": "object", + "required": [ + "staking_code_id" + ], + "properties": { + "staking_code_id": { + "description": "Code ID for staking contract to instantiate.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "unstaking_duration": { + "description": "See corresponding field in cw20-stake's instantiation. This will be used when instantiating the new staking contract.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "TokenInfo": { + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "address", + "staking_contract" + ], + "properties": { + "address": { + "description": "Address of an already instantiated cw20 token contract.", + "type": "string" + }, + "staking_contract": { + "description": "Information about the staking contract to use.", + "allOf": [ + { + "$ref": "#/definitions/StakingInfo" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "type": "object", + "required": [ + "code_id", + "decimals", + "initial_balances", + "label", + "name", + "staking_code_id", + "symbol" + ], + "properties": { + "code_id": { + "description": "Code ID for cw20 token contract.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "decimals": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "initial_balances": { + "type": "array", + "items": { + "$ref": "#/definitions/Cw20Coin" + } + }, + "initial_dao_balance": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + }, + "label": { + "description": "Label to use for instantiated cw20 contract.", + "type": "string" + }, + "marketing": { + "anyOf": [ + { + "$ref": "#/definitions/InstantiateMarketingInfo" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "staking_code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "symbol": { + "type": "string" + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "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": "Sets the active threshold to a new value. Only the instantiator this contract (a DAO most likely) may call this method.", + "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" + }, + "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": [ + { + "description": "Gets the address of the cw20-stake contract this voting module is wrapping.", + "type": "object", + "required": [ + "staking_contract" + ], + "properties": { + "staking_contract": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "active_threshold" + ], + "properties": { + "active_threshold": { + "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 + }, + { + "type": "object", + "required": [ + "token_contract" + ], + "properties": { + "token_contract": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "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" + } + } + }, + "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" + }, + "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" + }, + "staking_contract": { + "$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" + }, + "token_contract": { + "$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" + }, + "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/external/cw-fund-distributor/src/contract.rs b/contracts/external/cw-fund-distributor/src/contract.rs new file mode 100644 index 000000000..2e5d4b2d1 --- /dev/null +++ b/contracts/external/cw-fund-distributor/src/contract.rs @@ -0,0 +1,612 @@ +use crate::error::ContractError; +use crate::msg::{ + CW20EntitlementResponse, CW20Response, DenomResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, + NativeEntitlementResponse, QueryMsg, TotalPowerResponse, VotingContractResponse, +}; +use crate::state::{ + CW20_BALANCES, CW20_CLAIMS, DISTRIBUTION_HEIGHT, FUNDING_PERIOD_EXPIRATION, NATIVE_BALANCES, + NATIVE_CLAIMS, TOTAL_POWER, VOTING_CONTRACT, +}; +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, BankMsg, Binary, Coin, Decimal, Deps, DepsMut, Env, Fraction, MessageInfo, + Order, Response, StdError, StdResult, Uint128, WasmMsg, +}; +use cw2::set_contract_version; +use cw_paginate_storage::paginate_map; + +use dao_interface::voting; + +const CONTRACT_NAME: &str = "crates.io:cw-fund-distributor"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +type NativeClaimEntry = Result<((Addr, String), Uint128), StdError>; +type Cw20ClaimEntry = Result<((Addr, Addr), Uint128), StdError>; + +#[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)?; + + // store the height + DISTRIBUTION_HEIGHT.save(deps.storage, &msg.distribution_height)?; + + // get the funding expiration and store it + let funding_expiration_height = msg.funding_period.after(&env.block); + FUNDING_PERIOD_EXPIRATION.save(deps.storage, &funding_expiration_height)?; + + // validate the contract and save it + let voting_contract = deps.api.addr_validate(&msg.voting_contract)?; + VOTING_CONTRACT.save(deps.storage, &voting_contract)?; + + let total_power: voting::TotalPowerAtHeightResponse = deps.querier.query_wasm_smart( + voting_contract.clone(), + &voting::Query::TotalPowerAtHeight { + height: Some(env.block.height), + }, + )?; + // validate the total power and store it + if total_power.power.is_zero() { + return Err(ContractError::ZeroVotingPower {}); + } + TOTAL_POWER.save(deps.storage, &total_power.power)?; + + Ok(Response::default() + .add_attribute("distribution_height", env.block.height.to_string()) + .add_attribute("voting_contract", voting_contract) + .add_attribute("total_power", total_power.power)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { + sender: _, + amount, + msg: _, + }) => execute_fund_cw20(deps, env, info.sender, amount), + ExecuteMsg::FundNative {} => execute_fund_native(deps, env, info), + ExecuteMsg::ClaimCW20 { tokens } => execute_claim_cw20s(deps, env, info.sender, tokens), + ExecuteMsg::ClaimNatives { denoms } => { + execute_claim_natives(deps, env, info.sender, denoms) + } + ExecuteMsg::ClaimAll {} => execute_claim_all(deps, env, info.sender), + } +} + +pub fn execute_fund_cw20( + deps: DepsMut, + env: Env, + token: Addr, + amount: Uint128, +) -> Result { + let funding_deadline = FUNDING_PERIOD_EXPIRATION.load(deps.storage)?; + // if current block indicates claiming period, return an error + if funding_deadline.is_expired(&env.block) { + return Err(ContractError::FundDuringClaimingPeriod {}); + } + + if amount > Uint128::zero() { + CW20_BALANCES.update( + deps.storage, + token.clone(), + |current_balance| -> Result<_, ContractError> { + match current_balance { + // add the funding amount to current balance + Some(old_amount) => Ok(old_amount.checked_add(amount)?), + // with no existing balance, set it to the funding amount + None => Ok(amount), + } + }, + )?; + } + + Ok(Response::default() + .add_attribute("method", "fund_cw20") + .add_attribute("token", token) + .add_attribute("amount", amount)) +} + +pub fn execute_fund_native( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let funding_deadline = FUNDING_PERIOD_EXPIRATION.load(deps.storage)?; + // if current block indicates claiming period, return an error + if funding_deadline.is_expired(&env.block) { + return Err(ContractError::FundDuringClaimingPeriod {}); + } + + // collect a list of successful funding kv pairs + let mut attributes: Vec<(String, String)> = Vec::new(); + for coin in info.funds { + if coin.amount > Uint128::zero() { + NATIVE_BALANCES.update( + deps.storage, + coin.denom.clone(), + |current_balance| -> Result<_, ContractError> { + let new_amount = match current_balance { + // add the funding amount to current balance + Some(current_balance) => coin.amount.checked_add(current_balance)?, + // with no existing balance, set it to the funding amount + None => coin.amount, + }; + attributes.push((coin.denom, new_amount.to_string())); + Ok(new_amount) + }, + )?; + } + } + + Ok(Response::default() + .add_attribute("method", "fund_native") + .add_attributes(attributes)) +} + +fn get_entitlement( + distributor_funds: Uint128, + relative_share: Decimal, + previous_claim: Uint128, +) -> Result { + let total_share = + distributor_funds.multiply_ratio(relative_share.numerator(), relative_share.denominator()); + match total_share.checked_sub(previous_claim) { + Ok(entitlement) => Ok(entitlement), + Err(e) => Err(ContractError::OverflowErr(e)), + } +} + +fn get_relative_share(deps: &Deps, sender: Addr) -> Result { + let voting_contract = VOTING_CONTRACT.load(deps.storage)?; + let dist_height = DISTRIBUTION_HEIGHT.load(deps.storage)?; + let total_power = TOTAL_POWER.load(deps.storage)?; + + // find the voting power of sender at distributor instantiation + let voting_power: voting::VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( + voting_contract, + &voting::Query::VotingPowerAtHeight { + address: sender.to_string(), + height: Some(dist_height), + }, + )?; + // return senders share + Ok(Decimal::from_ratio(voting_power.power, total_power)) +} + +pub fn execute_claim_cw20s( + deps: DepsMut, + env: Env, + sender: Addr, + tokens: Vec, +) -> Result { + let funding_deadline = FUNDING_PERIOD_EXPIRATION.load(deps.storage)?; + // if current block indicates funding period, return an error + if !funding_deadline.is_expired(&env.block) { + return Err(ContractError::ClaimDuringFundingPeriod {}); + } + if tokens.is_empty() { + return Err(ContractError::EmptyClaim {}); + } + + let relative_share = get_relative_share(&deps.as_ref(), sender.clone())?; + let messages = get_cw20_claim_wasm_messages(tokens, deps, sender.clone(), relative_share)?; + + Ok(Response::default() + .add_attribute("method", "claim_cw20s") + .add_attribute("sender", sender) + .add_messages(messages)) +} + +/// Looks at the CW20_BALANCES map entries and returns a vector of WasmMsg::Execute +/// messages that entail the amount that the user is entitled to. +/// Updates the CW20_CLAIMS entries accordingly. +fn get_cw20_claim_wasm_messages( + tokens: Vec, + deps: DepsMut, + sender: Addr, + relative_share: Decimal, +) -> Result, ContractError> { + let mut messages: Vec = vec![]; + for addr in tokens { + // get the balance of distributor at instantiation + let bal = CW20_BALANCES.load(deps.storage, Addr::unchecked(addr.clone()))?; + + // check for any previous claims + let previous_claim = CW20_CLAIMS + .may_load( + deps.storage, + (sender.clone(), Addr::unchecked(addr.clone())), + )? + .unwrap_or_default(); + + // get % share of sender and subtract any previous claims + let entitlement = get_entitlement(bal, relative_share, previous_claim)?; + if !entitlement.is_zero() { + // reflect the new total claim amount + CW20_CLAIMS.update( + deps.storage, + (sender.clone(), Addr::unchecked(addr.clone())), + |claim| match claim { + Some(previous_claim) => previous_claim + .checked_add(entitlement) + .map_err(ContractError::OverflowErr), + None => Ok(entitlement), + }, + )?; + + messages.push(WasmMsg::Execute { + contract_addr: addr, + msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: sender.to_string(), + amount: entitlement, + })?, + funds: vec![], + }); + } + } + + Ok(messages) +} + +pub fn execute_claim_natives( + deps: DepsMut, + env: Env, + sender: Addr, + denoms: Vec, +) -> Result { + let funding_deadline = FUNDING_PERIOD_EXPIRATION.load(deps.storage)?; + // if current block indicates funding period, return an error + if !funding_deadline.is_expired(&env.block) { + return Err(ContractError::ClaimDuringFundingPeriod {}); + } + if denoms.is_empty() { + return Err(ContractError::EmptyClaim {}); + } + + // find the relative share of the distributor pool for the user + // and determine the native claim transfer amounts with it + let relative_share = get_relative_share(&deps.as_ref(), sender.clone())?; + let messages = get_native_claim_bank_messages(denoms, deps, sender.clone(), relative_share)?; + + Ok(Response::default() + .add_attribute("method", "claim_natives") + .add_attribute("sender", sender) + .add_messages(messages)) +} + +/// Looks at the NATIVE_BALANCES map entries and returns a vector of +/// BankMsg::Send messages that entail the amount that the user is +/// entitled to. Updates the NATIVE_CLAIMS entries accordingly. +fn get_native_claim_bank_messages( + denoms: Vec, + deps: DepsMut, + sender: Addr, + relative_share: Decimal, +) -> Result, ContractError> { + let mut messages: Vec = vec![]; + + for addr in denoms { + // get the balance of distributor at instantiation + let bal = NATIVE_BALANCES.load(deps.storage, addr.clone())?; + + // check for any previous claims + let previous_claim = NATIVE_CLAIMS + .may_load(deps.storage, (sender.clone(), addr.clone()))? + .unwrap_or_default(); + + // get % share of sender and subtract any previous claims + let entitlement = get_entitlement(bal, relative_share, previous_claim)?; + if !entitlement.is_zero() { + // reflect the new total claim amount + NATIVE_CLAIMS.update( + deps.storage, + (sender.clone(), addr.clone()), + |claim| match claim { + Some(previous_claim) => previous_claim + .checked_add(entitlement) + .map_err(ContractError::OverflowErr), + None => Ok(entitlement), + }, + )?; + + // collect the transfer messages + messages.push(BankMsg::Send { + to_address: sender.to_string(), + amount: vec![Coin { + denom: addr, + amount: entitlement, + }], + }); + } + } + Ok(messages) +} + +pub fn execute_claim_all( + mut deps: DepsMut, + env: Env, + sender: Addr, +) -> Result { + let funding_deadline = FUNDING_PERIOD_EXPIRATION.load(deps.storage)?; + // claims cannot happen during funding period + if !funding_deadline.is_expired(&env.block) { + return Err(ContractError::ClaimDuringFundingPeriod {}); + } + + // get the lists of tokens in distributor pool + let cw20s: Vec> = CW20_BALANCES + .keys(deps.storage, None, None, Order::Ascending) + .collect(); + let mut cw20_addresses: Vec = vec![]; + for entry in cw20s { + cw20_addresses.push(entry?.to_string()); + } + + let native_denoms: Vec> = NATIVE_BALANCES + .keys(deps.storage, None, None, Order::Ascending) + .collect(); + let mut denoms = vec![]; + for denom in native_denoms { + denoms.push(denom?); + } + + let relative_share = get_relative_share(&deps.as_ref(), sender.clone())?; + + // get the claim messages + let cw20_claim_msgs = get_cw20_claim_wasm_messages( + cw20_addresses, + deps.branch(), + sender.clone(), + relative_share, + )?; + let native_claim_msgs = + get_native_claim_bank_messages(denoms, deps.branch(), sender, relative_share)?; + + Ok(Response::default() + .add_attribute("method", "claim_all") + .add_messages(cw20_claim_msgs) + .add_messages(native_claim_msgs)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::VotingContract {} => query_voting_contract(deps), + QueryMsg::TotalPower {} => query_total_power(deps), + QueryMsg::NativeDenoms {} => query_native_denoms(deps), + QueryMsg::CW20Tokens {} => query_cw20_tokens(deps), + QueryMsg::NativeEntitlement { sender, denom } => { + query_native_entitlement(deps, sender, denom) + } + QueryMsg::CW20Entitlement { sender, token } => query_cw20_entitlement(deps, sender, token), + QueryMsg::NativeEntitlements { + sender, + start_at, + limit, + } => query_native_entitlements(deps, sender, start_at, limit), + QueryMsg::CW20Entitlements { + sender, + start_at, + limit, + } => query_cw20_entitlements(deps, sender, start_at, limit), + } +} + +pub fn query_voting_contract(deps: Deps) -> StdResult { + let contract = VOTING_CONTRACT.load(deps.storage)?; + let distribution_height = DISTRIBUTION_HEIGHT.load(deps.storage)?; + to_binary(&VotingContractResponse { + contract, + distribution_height, + }) +} + +pub fn query_total_power(deps: Deps) -> StdResult { + let total_power: Uint128 = TOTAL_POWER.may_load(deps.storage)?.unwrap_or_default(); + to_binary(&TotalPowerResponse { total_power }) +} + +pub fn query_native_denoms(deps: Deps) -> StdResult { + let native_balances = NATIVE_BALANCES.range(deps.storage, None, None, Order::Ascending); + + let mut denom_responses: Vec = vec![]; + for entry in native_balances { + let (denom, amount) = entry?; + denom_responses.push(DenomResponse { + contract_balance: amount, + denom, + }); + } + + to_binary(&denom_responses) +} + +pub fn query_cw20_tokens(deps: Deps) -> StdResult { + let cw20_balances = CW20_BALANCES.range(deps.storage, None, None, Order::Ascending); + + let mut cw20_responses: Vec = vec![]; + for cw20 in cw20_balances { + let (token, amount) = cw20?; + cw20_responses.push(CW20Response { + contract_balance: amount, + token: token.to_string(), + }); + } + + to_binary(&cw20_responses) +} + +pub fn query_native_entitlement(deps: Deps, sender: Addr, denom: String) -> StdResult { + let address = deps.api.addr_validate(sender.as_ref())?; + let prev_claim = NATIVE_CLAIMS + .may_load(deps.storage, (address, denom.clone()))? + .unwrap_or_default(); + let total_bal = NATIVE_BALANCES + .may_load(deps.storage, denom.clone())? + .unwrap_or_default(); + let relative_share = get_relative_share(&deps, sender)?; + + let total_share = + total_bal.multiply_ratio(relative_share.numerator(), relative_share.denominator()); + let entitlement = total_share.checked_sub(prev_claim)?; + + to_binary(&NativeEntitlementResponse { + amount: entitlement, + denom, + }) +} + +pub fn query_cw20_entitlement(deps: Deps, sender: Addr, token: String) -> StdResult { + let address = deps.api.addr_validate(sender.as_ref())?; + let token = Addr::unchecked(token); + + let prev_claim = CW20_CLAIMS + .may_load(deps.storage, (address, token.clone()))? + .unwrap_or_default(); + let total_bal = CW20_BALANCES + .may_load(deps.storage, token.clone())? + .unwrap_or_default(); + let relative_share = get_relative_share(&deps, sender)?; + + let total_share = + total_bal.multiply_ratio(relative_share.numerator(), relative_share.denominator()); + let entitlement = total_share.checked_sub(prev_claim)?; + + to_binary(&CW20EntitlementResponse { + amount: entitlement, + token_contract: token, + }) +} + +pub fn query_native_entitlements( + deps: Deps, + sender: Addr, + start_at: Option, + limit: Option, +) -> StdResult { + let address = deps.api.addr_validate(sender.as_ref())?; + let relative_share = get_relative_share(&deps, sender)?; + let natives = paginate_map(deps, &NATIVE_BALANCES, start_at, limit, Order::Descending)?; + + let mut entitlements: Vec = vec![]; + for (denom, amount) in natives { + let prev_claim = NATIVE_CLAIMS + .may_load(deps.storage, (address.clone(), denom.clone()))? + .unwrap_or_default(); + let total_share = + amount.multiply_ratio(relative_share.numerator(), relative_share.denominator()); + let entitlement = total_share.checked_sub(prev_claim)?; + + entitlements.push(NativeEntitlementResponse { + amount: entitlement, + denom, + }); + } + + to_binary(&entitlements) +} + +pub fn query_cw20_entitlements( + deps: Deps, + sender: Addr, + start_at: Option, + limit: Option, +) -> StdResult { + let address = deps.api.addr_validate(sender.as_ref())?; + let relative_share = get_relative_share(&deps, sender)?; + let start_at = start_at.map(|h| deps.api.addr_validate(&h)).transpose()?; + let cw20s = paginate_map(deps, &CW20_BALANCES, start_at, limit, Order::Descending)?; + + let mut entitlements: Vec = vec![]; + for (token, amount) in cw20s { + let prev_claim = CW20_CLAIMS + .may_load(deps.storage, (address.clone(), token.clone()))? + .unwrap_or_default(); + + let total_share = + amount.multiply_ratio(relative_share.numerator(), relative_share.denominator()); + let entitlement = total_share.checked_sub(prev_claim)?; + + entitlements.push(CW20EntitlementResponse { + amount: entitlement, + token_contract: token, + }); + } + + to_binary(&entitlements) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + match msg { + MigrateMsg::RedistributeUnclaimedFunds { + distribution_height, + } => execute_redistribute_unclaimed_funds(deps, distribution_height), + } +} + +// only cw_admin can call this +fn execute_redistribute_unclaimed_funds( + deps: DepsMut, + distribution_height: u64, +) -> Result { + // update the distribution height + DISTRIBUTION_HEIGHT.save(deps.storage, &distribution_height)?; + + // get performed claims of cw20 and native tokens + let performed_cw20_claims: Vec = CW20_CLAIMS + .range(deps.storage, None, None, Order::Descending) + .collect(); + let performed_native_claims: Vec = NATIVE_CLAIMS + .range(deps.storage, None, None, Order::Descending) + .collect(); + + // subtract every performed claim from the available distributor balance + for entry in performed_cw20_claims { + let ((_, cw20_addr), amount) = entry?; + CW20_BALANCES.update(deps.storage, cw20_addr.clone(), |bal| { + // should never hit the None arm in theory + match bal { + Some(cw20_balance) => cw20_balance + .checked_sub(amount) + .map_err(ContractError::OverflowErr), + None => Err(ContractError::Std(StdError::NotFound { + kind: cw20_addr.to_string(), + })), + } + })?; + } + + // subtract every performed claim from the available distributor balance + for entry in performed_native_claims { + let ((_, denom), amount) = entry?; + NATIVE_BALANCES.update(deps.storage, denom.clone(), |bal| { + // should never hit the None arm in theory + match bal { + Some(native_balance) => native_balance + .checked_sub(amount) + .map_err(ContractError::OverflowErr), + None => Err(ContractError::Std(StdError::NotFound { + kind: denom.to_string(), + })), + } + })?; + } + + // nullify previous claims + CW20_CLAIMS.clear(deps.storage); + NATIVE_CLAIMS.clear(deps.storage); + + Ok(Response::default().add_attribute("method", "redistribute_unclaimed_funds")) +} diff --git a/contracts/external/cw-fund-distributor/src/error.rs b/contracts/external/cw-fund-distributor/src/error.rs new file mode 100644 index 000000000..9193ced6b --- /dev/null +++ b/contracts/external/cw-fund-distributor/src/error.rs @@ -0,0 +1,29 @@ +use cosmwasm_std::{OverflowError, StdError}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Zero voting power")] + ZeroVotingPower {}, + + #[error("Zero funds")] + ZeroFunds {}, + + #[error("Cannot claim funds during the funding period")] + ClaimDuringFundingPeriod {}, + + #[error("Cannot fund the contract during the claim period")] + FundDuringClaimingPeriod {}, + + #[error("List of specified tokens to claim is empty")] + EmptyClaim {}, + + #[error("{0}")] + OverflowErr(#[from] OverflowError), +} diff --git a/contracts/external/cw-fund-distributor/src/lib.rs b/contracts/external/cw-fund-distributor/src/lib.rs new file mode 100644 index 000000000..57fa7953b --- /dev/null +++ b/contracts/external/cw-fund-distributor/src/lib.rs @@ -0,0 +1,10 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/external/cw-fund-distributor/src/msg.rs b/contracts/external/cw-fund-distributor/src/msg.rs new file mode 100644 index 000000000..abb39389d --- /dev/null +++ b/contracts/external/cw-fund-distributor/src/msg.rs @@ -0,0 +1,91 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_utils::Duration; + +#[cw_serde] +pub struct InstantiateMsg { + // To determine voting power + pub voting_contract: String, + // period after which the funds can be claimed + pub funding_period: Duration, + // snapshot for evaluating the voting power + pub distribution_height: u64, +} + +#[cw_serde] +pub enum ExecuteMsg { + Receive(cw20::Cw20ReceiveMsg), + FundNative {}, + ClaimCW20 { tokens: Vec }, + ClaimNatives { denoms: Vec }, + ClaimAll {}, +} + +#[cw_serde] +pub enum QueryMsg { + TotalPower {}, + VotingContract {}, + NativeDenoms {}, + CW20Tokens {}, + NativeEntitlement { + sender: Addr, + denom: String, + }, + CW20Entitlement { + sender: Addr, + token: String, + }, + NativeEntitlements { + sender: Addr, + start_at: Option, + limit: Option, + }, + CW20Entitlements { + sender: Addr, + start_at: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct VotingContractResponse { + // voting power contract being used + pub contract: Addr, + // height at which voting power is being determined + pub distribution_height: u64, +} + +#[cw_serde] +pub struct TotalPowerResponse { + // total power at the distribution height + pub total_power: Uint128, +} + +#[cw_serde] +pub enum MigrateMsg { + RedistributeUnclaimedFunds { distribution_height: u64 }, +} + +#[cw_serde] +pub struct DenomResponse { + pub contract_balance: Uint128, + pub denom: String, +} + +#[cw_serde] +pub struct CW20Response { + pub contract_balance: Uint128, + pub token: String, +} + +#[cw_serde] +pub struct NativeEntitlementResponse { + pub amount: Uint128, + pub denom: String, +} + +#[cw_serde] +pub struct CW20EntitlementResponse { + pub amount: Uint128, + pub token_contract: Addr, +} diff --git a/contracts/external/cw-fund-distributor/src/state.rs b/contracts/external/cw-fund-distributor/src/state.rs new file mode 100644 index 000000000..cd3cb110c --- /dev/null +++ b/contracts/external/cw-fund-distributor/src/state.rs @@ -0,0 +1,24 @@ +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::{Item, Map}; +use cw_utils::Expiration; + +/// block height for distribution snapshot +pub const DISTRIBUTION_HEIGHT: Item = Item::new("distribution_height"); +/// period during which the contract can be funded +/// exclusive of the expiration block +pub const FUNDING_PERIOD_EXPIRATION: Item = Item::new("funding_period"); +/// voting contract to determine the voting power +pub const VOTING_CONTRACT: Item = Item::new("voting_contract"); +/// total voting power at the distribution height +pub const TOTAL_POWER: Item = Item::new("total_power"); + +/// maps token address to the amount being distributed +pub const CW20_BALANCES: Map = Map::new("cw20_balances"); +pub const NATIVE_BALANCES: Map = Map::new("native_balances"); + +/// maps (ADDRESS, TOKEN_ADDRESS) to amounts +/// that have been claimed by the address +pub const CW20_CLAIMS: Map<(Addr, Addr), Uint128> = Map::new("cw20_claims"); +/// maps (ADDRESS, NATIVE_DENOM) to amounts +/// that have been claimed by the address +pub const NATIVE_CLAIMS: Map<(Addr, String), Uint128> = Map::new("native_claims"); diff --git a/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs b/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs new file mode 100644 index 000000000..ff07edbff --- /dev/null +++ b/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs @@ -0,0 +1,304 @@ +use crate::msg::ExecuteMsg::ClaimAll; +use crate::msg::{ExecuteMsg, InstantiateMsg}; +use cosmwasm_std::{to_binary, Addr, Binary, Coin, Empty, Uint128}; +use cw20::{BalanceResponse, Cw20Coin}; +use cw_multi_test::{next_block, App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; +use cw_utils::Duration; + +const CREATOR_ADDR: &str = "creator"; +const FEE_DENOM: &str = "ujuno"; + +struct BaseTest { + app: App, + distributor_address: Addr, +} + +fn distributor_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn staked_balances_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw20_staked::contract::execute, + dao_voting_cw20_staked::contract::instantiate, + dao_voting_cw20_staked::contract::query, + ) + .with_reply(dao_voting_cw20_staked::contract::reply); + Box::new(contract) +} + +fn cw20_staking_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_stake::contract::execute, + cw20_stake::contract::instantiate, + cw20_stake::contract::query, + ); + Box::new(contract) +} + +fn instantiate_cw20( + app: &mut App, + sender: Addr, + initial_balances: Vec, + name: String, + symbol: String, +) -> Addr { + let cw20_id = app.store_code(cw20_contract()); + let msg = cw20_base::msg::InstantiateMsg { + name, + symbol, + decimals: 6, + initial_balances, + mint: None, + marketing: None, + }; + + app.instantiate_contract(cw20_id, sender, &msg, &[], "cw20", None) + .unwrap() +} + +fn setup_test(initial_balances: Vec) -> BaseTest { + let mut app = App::default(); + let distributor_id = app.store_code(distributor_contract()); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balances_voting_contract()); + let stake_cw20_id = app.store_code(cw20_staking_contract()); + + let voting_address = app + .instantiate_contract( + voting_id, + Addr::unchecked(CREATOR_ADDR), + &dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: None, + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token.".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: initial_balances.clone(), + marketing: None, + staking_code_id: stake_cw20_id, + unstaking_duration: None, + initial_dao_balance: None, + }, + }, + &[], + "voting contract", + None, + ) + .unwrap(); + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_address.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_address.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::TokenContract {}, + ) + .unwrap(); + + for Cw20Coin { address, amount } in initial_balances { + app.execute_contract( + Addr::unchecked(address), + token_contract.clone(), + &cw20_base::msg::ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount, + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + } + + app.update_block(next_block); + + let distribution_contract = app + .instantiate_contract( + distributor_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + voting_contract: voting_address.to_string(), + funding_period: Duration::Height(10), + distribution_height: app.block_info().height, + }, + &[], + "distribution contract", + Some(CREATOR_ADDR.parse().unwrap()), + ) + .unwrap(); + + BaseTest { + app, + distributor_address: distribution_contract, + } +} + +// This is to attempt to simulate a situation where +// someone would spam a dao treasury with a lot of native tokens +#[test] +pub fn test_claim_lots_of_native_tokens() { + let BaseTest { + mut app, + distributor_address, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + + let token_count = 500; + // mint and fund the distributor contract with + // a bunch of native tokens + for n in 1..token_count { + let denom = FEE_DENOM.to_owned() + &n.to_string(); + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: CREATOR_ADDR.to_string(), + amount: vec![Coin { + amount, + denom: denom.clone(), + }], + })) + .unwrap(); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + distributor_address.clone(), + &ExecuteMsg::FundNative {}, + &[Coin { + amount, + denom: denom.clone(), + }], + ) + .unwrap(); + } + + app.update_block(|block| block.height += 11); + + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address, + &ClaimAll {}, + &[], + ) + .unwrap(); + + // assert that all the claims succeeded + for n in 1..token_count { + let denom = FEE_DENOM.to_owned() + &n.to_string(); + let expected_balance = Uint128::new(166666); + let user_balance_after_claim = app + .wrap() + .query_balance("bekauz".to_string(), denom) + .unwrap(); + assert_eq!(expected_balance, user_balance_after_claim.amount); + } +} + +// This is to attempt to simulate a situation where +// the distributor contract gets funded with a lot +// of cw20 tokens +#[test] +pub fn test_claim_lots_of_cw20s() { + let BaseTest { + mut app, + distributor_address, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + + // mint and fund (spam) the distributor contract with + // a bunch of tokens + let cw20_addresses: Vec = (1..1000) + .map(|n| { + let name = FEE_DENOM.to_owned() + &n.to_string(); + let cw20_addr = instantiate_cw20( + &mut app, + Addr::unchecked(CREATOR_ADDR), + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount, + }], + name, + "shitcoin".to_string(), + ); + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + cw20_addr.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: distributor_address.to_string(), + amount, + msg: Binary::default(), + }, + &[], + ) + .unwrap(); + cw20_addr + }) + .collect(); + + app.update_block(|block| block.height += 11); + + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address, + &ClaimAll {}, + &[], + ) + .unwrap(); + + let expected_balance = Uint128::new(166666); + + // assert that all the claims succeeded + cw20_addresses.into_iter().for_each(|addr| { + let user_balance_after_claim: BalanceResponse = app + .wrap() + .query_wasm_smart( + addr, + &cw20::Cw20QueryMsg::Balance { + address: "bekauz".to_string(), + }, + ) + .unwrap(); + assert_eq!(expected_balance, user_balance_after_claim.balance); + }); +} diff --git a/contracts/external/cw-fund-distributor/src/testing/mod.rs b/contracts/external/cw-fund-distributor/src/testing/mod.rs new file mode 100644 index 000000000..9f1e9f269 --- /dev/null +++ b/contracts/external/cw-fund-distributor/src/testing/mod.rs @@ -0,0 +1,2 @@ +mod adversarial_tests; +mod tests; diff --git a/contracts/external/cw-fund-distributor/src/testing/tests.rs b/contracts/external/cw-fund-distributor/src/testing/tests.rs new file mode 100644 index 000000000..6c3f2a254 --- /dev/null +++ b/contracts/external/cw-fund-distributor/src/testing/tests.rs @@ -0,0 +1,1687 @@ +use crate::msg::{ + CW20EntitlementResponse, CW20Response, DenomResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, + NativeEntitlementResponse, QueryMsg, TotalPowerResponse, VotingContractResponse, +}; +use crate::ContractError; +use cosmwasm_std::{to_binary, Addr, Binary, Coin, Empty, Uint128, WasmMsg}; +use cw20::Cw20Coin; +use cw_multi_test::{next_block, App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; + +use crate::msg::ExecuteMsg::{ClaimAll, ClaimCW20, ClaimNatives}; +use crate::msg::QueryMsg::TotalPower; +use cosmwasm_std::StdError::GenericErr; +use cw_utils::Duration; + +const CREATOR_ADDR: &str = "creator"; +const FEE_DENOM: &str = "ujuno"; + +fn distributor_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn staked_balances_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw20_staked::contract::execute, + dao_voting_cw20_staked::contract::instantiate, + dao_voting_cw20_staked::contract::query, + ) + .with_reply(dao_voting_cw20_staked::contract::reply); + Box::new(contract) +} + +fn cw20_staking_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_stake::contract::execute, + cw20_stake::contract::instantiate, + cw20_stake::contract::query, + ); + Box::new(contract) +} + +struct BaseTest { + app: App, + distributor_address: Addr, + token_address: Addr, +} + +fn setup_test(initial_balances: Vec) -> BaseTest { + let mut app = App::default(); + let distributor_id = app.store_code(distributor_contract()); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balances_voting_contract()); + let stake_cw20_id = app.store_code(cw20_staking_contract()); + + let voting_address = app + .instantiate_contract( + voting_id, + Addr::unchecked(CREATOR_ADDR), + &dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: None, + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token.".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: initial_balances.clone(), + marketing: None, + staking_code_id: stake_cw20_id, + unstaking_duration: None, + initial_dao_balance: None, + }, + }, + &[], + "voting contract", + None, + ) + .unwrap(); + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_address.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_address.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::TokenContract {}, + ) + .unwrap(); + + for Cw20Coin { address, amount } in initial_balances { + app.execute_contract( + Addr::unchecked(address), + token_contract.clone(), + &cw20_base::msg::ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount, + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + } + + app.update_block(next_block); + + let distribution_contract = app + .instantiate_contract( + distributor_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + voting_contract: voting_address.to_string(), + funding_period: Duration::Height(10), + distribution_height: app.block_info().height, + }, + &[], + "distribution contract", + Some(CREATOR_ADDR.parse().unwrap()), + ) + .unwrap(); + + BaseTest { + app, + distributor_address: distribution_contract, + token_address: token_contract, + } +} + +pub fn query_cw20_balance( + app: &mut App, + token_address: Addr, + account: Addr, +) -> cw20::BalanceResponse { + app.wrap() + .query_wasm_smart( + token_address, + &cw20::Cw20QueryMsg::Balance { + address: account.into_string(), + }, + ) + .unwrap() +} + +pub fn query_native_balance(app: &mut App, account: Addr) -> Coin { + app.wrap() + .query_balance(account.to_string(), FEE_DENOM.to_string()) + .unwrap() +} + +pub fn mint_cw20s( + app: &mut App, + recipient: Addr, + token_address: Addr, + amount: Uint128, + sender: Addr, +) { + app.execute_contract( + sender, + token_address, + &cw20::Cw20ExecuteMsg::Mint { + recipient: recipient.to_string(), + amount, + }, + &[], + ) + .unwrap(); +} + +pub fn mint_natives(app: &mut App, recipient: Addr, amount: Uint128) { + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: recipient.to_string(), + amount: vec![Coin { + amount, + denom: FEE_DENOM.to_string(), + }], + })) + .unwrap(); +} + +pub fn fund_distributor_contract_cw20( + app: &mut App, + distributor_address: Addr, + token_address: Addr, + amount: Uint128, + sender: Addr, +) { + app.execute_contract( + sender, + token_address, + &cw20::Cw20ExecuteMsg::Send { + contract: distributor_address.to_string(), + amount, + msg: Binary::default(), + }, + &[], + ) + .unwrap(); +} + +pub fn fund_distributor_contract_natives( + app: &mut App, + distributor_address: Addr, + amount: Uint128, + sender: Addr, +) { + app.execute_contract( + Addr::unchecked(sender), + distributor_address, + &ExecuteMsg::FundNative {}, + &[Coin { + amount, + denom: FEE_DENOM.to_string(), + }], + ) + .unwrap(); +} + +#[test] +fn test_instantiate_fails_given_invalid_voting_contract_address() { + let mut app = App::default(); + let distributor_id = app.store_code(distributor_contract()); + + let expected_error: ContractError = app + .instantiate_contract( + distributor_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + voting_contract: "invalid address".to_string(), + funding_period: Duration::Height(10), + distribution_height: app.block_info().height, + }, + &[], + "distribution contract", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!( + expected_error, + ContractError::Std(GenericErr { .. }) + )); +} + +#[test] +fn test_instantiate_fails_zero_voting_power() { + let mut app = App::default(); + let distributor_id = app.store_code(distributor_contract()); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balances_voting_contract()); + let stake_cw20_id = app.store_code(cw20_staking_contract()); + + let initial_balances = vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]; + + let voting_address = app + .instantiate_contract( + voting_id, + Addr::unchecked(CREATOR_ADDR), + &dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: None, + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token.".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances, + marketing: None, + staking_code_id: stake_cw20_id, + unstaking_duration: None, + initial_dao_balance: None, + }, + }, + &[], + "voting contract", + None, + ) + .unwrap(); + + app.update_block(next_block); + + let expected_error: ContractError = app + .instantiate_contract( + distributor_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + voting_contract: voting_address.to_string(), + funding_period: Duration::Height(10), + distribution_height: app.block_info().height, + }, + &[], + "distribution contract", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(expected_error, ContractError::ZeroVotingPower {})); +} + +#[test] +fn test_instantiate_cw_fund_distributor() { + let BaseTest { + app, + distributor_address, + .. + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let total_power: TotalPowerResponse = app + .wrap() + .query_wasm_smart(distributor_address, &TotalPower {}) + .unwrap(); + + // assert total power has been set correctly + assert_eq!(total_power.total_power, Uint128::new(30)); +} + +#[test] +fn test_fund_cw20() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + let first_fund_amount = Uint128::new(20000); + // fund the contract for the first time + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address.clone(), + first_fund_amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // query the balance of distributor contract + let balance = query_cw20_balance(&mut app, token_address.clone(), distributor_address.clone()); + // assert correct first funding + assert_eq!(balance.balance, first_fund_amount); + + let second_fund_amount = amount.checked_sub(first_fund_amount).unwrap(); + // fund the remaining part + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address.clone(), + second_fund_amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // query the balance of distributor contract + let balance = query_cw20_balance(&mut app, token_address, distributor_address); + // assert full amount is funded + assert_eq!(balance.balance, amount); +} + +#[test] +pub fn test_fund_cw20_zero_amount() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_address, + &cw20::Cw20ExecuteMsg::Send { + contract: distributor_address.to_string(), + amount: Uint128::zero(), // since cw20-base v1.1.0 this is allowed + msg: Binary::default(), + }, + &[], + ) + .unwrap(); +} + +#[test] +pub fn test_fund_natives() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + let balance = query_native_balance(&mut app, distributor_address.clone()).amount; + assert_eq!(amount, balance); + + // fund again with an existing balance with an existing balance, fund + mint_natives(&mut app, Addr::unchecked("bekauz"), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked("bekauz"), + ); + + let balance = query_native_balance(&mut app, distributor_address).amount; + assert_eq!(amount * Uint128::new(2), balance); +} + +#[test] +#[should_panic(expected = "Cannot transfer empty coins amount")] +pub fn test_fund_natives_zero_amount() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + + // sending multiple native coins including zero amount + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + distributor_address.clone(), + &ExecuteMsg::FundNative {}, + &[ + Coin { + amount: Uint128::zero(), + denom: FEE_DENOM.to_string(), + }, + Coin { + amount: Uint128::one(), + denom: FEE_DENOM.to_string(), + }, + ], + ) + .unwrap(); + + // should have filtered out the zero amount coins + let balance = query_native_balance(&mut app, distributor_address.clone()); + assert_eq!(balance.amount, Uint128::one()); + + // sending a single coin with 0 amount should throw an error + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + distributor_address, + &ExecuteMsg::FundNative {}, + &[Coin { + amount: Uint128::zero(), + denom: FEE_DENOM.to_string(), + }], + ) + .unwrap(); +} + +#[test] +pub fn test_claim_cw20() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // fund the contract + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // query the balance of distributor contract + let balance = query_cw20_balance(&mut app, token_address.clone(), distributor_address.clone()); + + assert_eq!(balance.balance, amount); + app.update_block(|block| block.height += 11); + + // claim the tokens + // should result in an entitlement of (10/(10 + 20))% + // of funds in the distributor contract (166666.666667 floored) + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimCW20 { + tokens: vec![token_address.to_string()], + }, + &[], + ) + .unwrap(); + + // assert user has received the expected funds + let expected_balance = Uint128::new(166666); + + let user_balance_after_claim = + query_cw20_balance(&mut app, token_address.clone(), Addr::unchecked("bekauz")); + assert_eq!(expected_balance, user_balance_after_claim.balance); + + // assert funds have been deducted from distributor + let distributor_balance_after_claim = + query_cw20_balance(&mut app, token_address, distributor_address); + assert_eq!( + amount - expected_balance, + distributor_balance_after_claim.balance + ); +} + +#[test] +pub fn test_claim_cw20_twice() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // fund the contract + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // query the balance of distributor contract + let balance = query_cw20_balance(&mut app, token_address.clone(), distributor_address.clone()); + + assert_eq!(balance.balance, amount); + + app.update_block(|block| block.height += 11); + + // claim the tokens twice + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimCW20 { + tokens: vec![token_address.to_string()], + }, + &[], + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimCW20 { + tokens: vec![token_address.to_string()], + }, + &[], + ) + .unwrap(); + + // assert user has received the expected funds (once) + let expected_balance = Uint128::new(166666); + + let user_balance_after_claim = + query_cw20_balance(&mut app, token_address.clone(), Addr::unchecked("bekauz")); + + // assert only a single claim has been deducted from the distributor + let distributor_balance_after_claim = + query_cw20_balance(&mut app, token_address, distributor_address); + + assert_eq!( + amount - expected_balance, + distributor_balance_after_claim.balance + ); + assert_eq!(expected_balance, user_balance_after_claim.balance); +} + +#[test] +pub fn test_claim_cw20s_empty_list() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // fund the contract + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address, + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + app.update_block(|b| b.height += 11); + + let err: ContractError = app + .execute_contract( + Addr::unchecked("bekauz"), + distributor_address, + &ClaimCW20 { tokens: vec![] }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // assert that the claim contained no tokens + assert!(matches!(err, ContractError::EmptyClaim {})); +} + +#[test] +pub fn test_claim_natives_twice() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + app.update_block(|block| block.height += 11); + + // claim twice + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimNatives { + denoms: vec![FEE_DENOM.to_string()], + }, + &[], + ) + .unwrap(); + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimNatives { + denoms: vec![FEE_DENOM.to_string()], + }, + &[], + ) + .unwrap(); + + let expected_balance = Uint128::new(166666); + let user_balance_after_claim = query_native_balance(&mut app, Addr::unchecked("bekauz")); + + let distributor_balance_after_claim = query_native_balance(&mut app, distributor_address); + + // assert only a single claim has occurred on both + // user and distributor level + assert_eq!(expected_balance, user_balance_after_claim.amount); + assert_eq!( + amount - expected_balance, + distributor_balance_after_claim.amount + ); +} + +#[test] +pub fn test_claim_natives() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + app.update_block(|block| block.height += 11); + + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimNatives { + denoms: vec![FEE_DENOM.to_string()], + }, + &[], + ) + .unwrap(); + + // 1/3rd of the total amount (500000) floored down + let expected_balance = Uint128::new(166666); + + let user_balance_after_claim = query_native_balance(&mut app, Addr::unchecked("bekauz")); + assert_eq!(expected_balance, user_balance_after_claim.amount); + + // assert funds have been deducted from distributor + let distributor_balance_after_claim = query_native_balance(&mut app, distributor_address); + assert_eq!( + amount - expected_balance, + distributor_balance_after_claim.amount + ); +} + +#[test] +pub fn test_claim_all() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + // mint and fund the distributor with native & cw20 tokens + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // claiming period + app.update_block(|block| block.height += 11); + + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimAll {}, + &[], + ) + .unwrap(); + + let expected_balance = Uint128::new(166666); + + // assert the native claim + let user_balance_after_claim = query_native_balance(&mut app, Addr::unchecked("bekauz")); + let distributor_balance_after_claim = + query_native_balance(&mut app, distributor_address.clone()); + // assert funds have been deducted from distributor and + // user received the funds (native) + assert_eq!(expected_balance, user_balance_after_claim.amount); + assert_eq!( + amount - expected_balance, + distributor_balance_after_claim.amount + ); + + // assert the cw20 claim + let user_balance_after_claim = + query_cw20_balance(&mut app, token_address.clone(), Addr::unchecked("bekauz")); + let distributor_balance_after_claim = + query_cw20_balance(&mut app, token_address, distributor_address); + // assert funds have been deducted from distributor and + // user received the funds (cw20) + assert_eq!(expected_balance, user_balance_after_claim.balance); + assert_eq!( + amount - expected_balance, + distributor_balance_after_claim.balance + ); +} + +#[test] +pub fn test_claim_natives_empty_list_of_denoms() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + app.update_block(|block| block.height += 11); + + let err: ContractError = app + .execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimNatives { denoms: vec![] }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::EmptyClaim {})); + + let user_balance_after_claim = query_native_balance(&mut app, Addr::unchecked("bekauz")); + assert_eq!(Uint128::zero(), user_balance_after_claim.amount); + + // assert no funds have been deducted from distributor + let distributor_balance_after_claim = query_native_balance(&mut app, distributor_address); + assert_eq!(amount, distributor_balance_after_claim.amount); +} + +#[test] +pub fn test_redistribute_unclaimed_funds() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + let distributor_id = app.store_code(distributor_contract()); + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + app.update_block(|block| block.height += 11); + + // claim the initial allocation equal to 1/3rd of 500000 + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimNatives { + denoms: vec![FEE_DENOM.to_string()], + }, + &[], + ) + .unwrap(); + + let expected_balance = Uint128::new(166666); + let user_balance_after_claim = query_native_balance(&mut app, Addr::unchecked("bekauz")); + assert_eq!(expected_balance, user_balance_after_claim.amount); + + // some time passes.. + app.update_block(next_block); + + let migrate_msg = &MigrateMsg::RedistributeUnclaimedFunds { + distribution_height: app.block_info().height, + }; + + // reclaim 2/3rds of tokens back from users who failed + // to claim back into the claimable distributor pool + app.execute( + Addr::unchecked(CREATOR_ADDR), + WasmMsg::Migrate { + contract_addr: distributor_address.to_string(), + new_code_id: distributor_id, + msg: to_binary(migrate_msg).unwrap(), + } + .into(), + ) + .unwrap(); + + // should equal to 500000 - 166666 + let distributor_balance = query_native_balance(&mut app, distributor_address.clone()); + // should equal to 1/3rd (rounded up) of the pool + // after the initial claim + let expected_claim = distributor_balance + .amount + .checked_multiply_ratio(Uint128::new(10), Uint128::new(30)) + .unwrap(); + assert_eq!(distributor_balance.amount, Uint128::new(333334)); + assert_eq!(expected_claim, Uint128::new(111111)); + + app.update_block(next_block); + + // claim the newly made available tokens + app.execute_contract( + Addr::unchecked("bekauz"), + distributor_address, + &ClaimNatives { + denoms: vec![FEE_DENOM.to_string()], + }, + &[], + ) + .unwrap(); + + let user_balance_after_second_claim = query_native_balance(&mut app, Addr::unchecked("bekauz")); + assert_eq!( + user_balance_after_second_claim.amount, + expected_balance + expected_claim + ); +} + +#[test] +#[should_panic(expected = "Only admin can migrate contract")] +pub fn test_unauthorized_redistribute_unclaimed_funds() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![ + Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(20), + }, + ]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + let distributor_id = app.store_code(distributor_contract()); + let migrate_msg = &MigrateMsg::RedistributeUnclaimedFunds { + distribution_height: app.block_info().height, + }; + + // panics on non-admin sender + app.execute( + Addr::unchecked("bekauz"), + WasmMsg::Migrate { + contract_addr: distributor_address.to_string(), + new_code_id: distributor_id, + msg: to_binary(migrate_msg).unwrap(), + } + .into(), + ) + .unwrap(); +} + +#[test] +pub fn test_claim_cw20_during_funding_period() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // fund the contract + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // query the balance of distributor contract + let balance = query_cw20_balance(&mut app, token_address.clone(), distributor_address.clone()); + assert_eq!(balance.balance, amount); + + // attempt to claim during funding period + let err: ContractError = app + .execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimCW20 { + tokens: vec![token_address.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // assert the error and that the balance of distributor did not change + assert!(matches!(err, ContractError::ClaimDuringFundingPeriod {})); + let balance = query_cw20_balance(&mut app, token_address, distributor_address); + assert_eq!(balance.balance, amount); +} + +#[test] +pub fn test_claim_natives_during_funding_period() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + + // fund the contract + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + let balance = query_native_balance(&mut app, distributor_address.clone()).amount; + assert_eq!(amount, balance); + + // attempt to claim during the funding period + let err: ContractError = app + .execute_contract( + Addr::unchecked("bekauz"), + distributor_address.clone(), + &ClaimNatives { + denoms: vec![FEE_DENOM.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // assert that the expected error and that balance did not change + assert!(matches!(err, ContractError::ClaimDuringFundingPeriod {})); + let balance = query_native_balance(&mut app, distributor_address).amount; + assert_eq!(amount, balance); +} + +#[test] +pub fn test_claim_all_during_funding_period() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // attempt to claim during the funding period + let err: ContractError = app + .execute_contract( + Addr::unchecked("bekauz"), + distributor_address, + &ClaimNatives { + denoms: vec![FEE_DENOM.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::ClaimDuringFundingPeriod {})); +} + +#[test] +pub fn test_fund_cw20_during_claiming_period() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // skip into the claiming period + app.update_block(|block| block.height += 11); + + // attempt to fund the contract + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_address, + &cw20::Cw20ExecuteMsg::Send { + contract: distributor_address.to_string(), + amount, + msg: Binary::default(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::FundDuringClaimingPeriod {})); +} + +#[test] +pub fn test_fund_natives_during_claiming_period() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let amount = Uint128::new(500000); + + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + + // skip into the claim period + app.update_block(|block| block.height += 11); + + // attempt to fund + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + distributor_address, + &ExecuteMsg::FundNative {}, + &[Coin { + amount, + denom: FEE_DENOM.to_string(), + }], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::FundDuringClaimingPeriod {})); +} + +#[test] +fn test_query_cw20_entitlements() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let res: Vec = app + .wrap() + .query_wasm_smart( + distributor_address.clone(), + &QueryMsg::CW20Entitlements { + sender: Addr::unchecked("bekauz"), + start_at: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(res.len(), 0); + + // fund the contract with some cw20 tokens + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + let res: Vec = app + .wrap() + .query_wasm_smart( + distributor_address, + &QueryMsg::CW20Entitlements { + sender: Addr::unchecked("bekauz"), + start_at: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(res.len(), 1); + let entitlement = res.get(0).unwrap(); + assert_eq!(entitlement.amount.u128(), 500000); + assert_eq!(entitlement.token_contract, token_address); +} + +#[test] +fn test_query_native_entitlements() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let res: Vec = app + .wrap() + .query_wasm_smart( + distributor_address.clone(), + &QueryMsg::NativeEntitlements { + sender: Addr::unchecked("bekauz"), + start_at: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(res.len(), 0); + + // fund the contract with some native tokens + let amount = Uint128::new(500000); + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + let res: Vec = app + .wrap() + .query_wasm_smart( + distributor_address, + &QueryMsg::NativeEntitlements { + sender: Addr::unchecked("bekauz"), + start_at: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(res.len(), 1); + let entitlement = res.get(0).unwrap(); + assert_eq!(entitlement.amount.u128(), 500000); + assert_eq!(entitlement.denom, FEE_DENOM); +} + +#[test] +fn test_query_cw20_entitlement() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + // fund the contract with some cw20 tokens + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + app.update_block(next_block); + + // query and assert the expected entitlement + let res: CW20EntitlementResponse = query_cw20_entitlement( + app, + distributor_address.to_string(), + Addr::unchecked("bekauz"), + token_address.to_string(), + ); + assert_eq!(res.amount.u128(), 500000); + assert_eq!(res.token_contract.to_string(), "contract1"); +} + +fn query_cw20_entitlement( + app: App, + distributor_address: String, + sender: Addr, + token: String, +) -> CW20EntitlementResponse { + app.wrap() + .query_wasm_smart( + distributor_address, + &QueryMsg::CW20Entitlement { sender, token }, + ) + .unwrap() +} + +fn query_native_entitlement( + app: App, + distributor_address: String, + sender: Addr, + denom: String, +) -> NativeEntitlementResponse { + app.wrap() + .query_wasm_smart( + distributor_address, + &QueryMsg::NativeEntitlement { sender, denom }, + ) + .unwrap() +} + +#[test] +fn test_query_native_entitlement() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + // fund the contract with some native tokens + let amount = Uint128::new(500000); + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // assert the expected native entitlement + let res = query_native_entitlement( + app, + distributor_address.to_string(), + Addr::unchecked("bekauz"), + FEE_DENOM.to_string(), + ); + assert_eq!(res.amount.u128(), 500000); + assert_eq!(res.denom, FEE_DENOM.to_string()); +} + +#[test] +fn test_query_cw20_tokens() { + let BaseTest { + mut app, + distributor_address, + token_address, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + // no cw20s expected + let res: Vec = app + .wrap() + .query_wasm_smart(distributor_address.clone(), &QueryMsg::CW20Tokens {}) + .unwrap(); + + assert_eq!(res.len(), 0); + + // mint and fund the distributor with a cw20 token + let amount = Uint128::new(500000); + mint_cw20s( + &mut app, + Addr::unchecked(CREATOR_ADDR), + token_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + fund_distributor_contract_cw20( + &mut app, + distributor_address.clone(), + token_address, + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + // assert distributor now contains one expected cw20 token + + let res: Vec = app + .wrap() + .query_wasm_smart(distributor_address, &QueryMsg::CW20Tokens {}) + .unwrap(); + + assert_eq!(res.len(), 1); + let cw20 = res.get(0).unwrap(); + assert_eq!(cw20.token, "contract1"); + assert_eq!(cw20.contract_balance.u128(), 500000); +} + +#[test] +fn test_query_native_denoms() { + let BaseTest { + mut app, + distributor_address, + token_address: _, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + // no denoms expected + let res: Vec = app + .wrap() + .query_wasm_smart(distributor_address.clone(), &QueryMsg::NativeDenoms {}) + .unwrap(); + + assert_eq!(res.len(), 0); + + // mint and fund the distributor with a native token + let amount = Uint128::new(500000); + mint_natives(&mut app, Addr::unchecked(CREATOR_ADDR), amount); + fund_distributor_contract_natives( + &mut app, + distributor_address.clone(), + amount, + Addr::unchecked(CREATOR_ADDR), + ); + + let res: Vec = app + .wrap() + .query_wasm_smart(distributor_address, &QueryMsg::NativeDenoms {}) + .unwrap(); + + // assert distributor now contains one expected native token + assert_eq!(res.len(), 1); + let denom = res.get(0).unwrap(); + assert_eq!(denom.denom, FEE_DENOM.to_string()); + assert_eq!(denom.contract_balance.u128(), 500000); +} + +#[test] +fn test_query_total_power() { + let BaseTest { + app, + distributor_address, + token_address: _, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let res: TotalPowerResponse = app + .wrap() + .query_wasm_smart(distributor_address, &QueryMsg::TotalPower {}) + .unwrap(); + + assert_eq!(10, res.total_power.u128()); +} + +#[test] +fn test_query_voting_contract() { + let BaseTest { + app, + distributor_address, + token_address: _, + } = setup_test(vec![Cw20Coin { + address: "bekauz".to_string(), + amount: Uint128::new(10), + }]); + + let res: VotingContractResponse = app + .wrap() + .query_wasm_smart(distributor_address, &QueryMsg::VotingContract {}) + .unwrap(); + + assert_eq!("contract0", res.contract.to_string()); + assert_eq!(12346, res.distribution_height); +} diff --git a/contracts/external/cw-payroll-factory/.cargo/config b/contracts/external/cw-payroll-factory/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/external/cw-payroll-factory/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/external/cw-payroll-factory/Cargo.toml b/contracts/external/cw-payroll-factory/Cargo.toml new file mode 100644 index 000000000..9b89969a5 --- /dev/null +++ b/contracts/external/cw-payroll-factory/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name ="cw-payroll-factory" +authors = ["Jake Hartnell"] +description = "A CosmWasm factory contract for instantiating a payroll contract." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-denom = { workspace = true } +cw-ownable = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +thiserror = { workspace = true } +cw-vesting = { workspace = true, features = ["library"] } +cw-utils = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +wynd-utils = { workspace = true } diff --git a/contracts/external/cw-payroll-factory/README.md b/contracts/external/cw-payroll-factory/README.md new file mode 100644 index 000000000..553134a96 --- /dev/null +++ b/contracts/external/cw-payroll-factory/README.md @@ -0,0 +1,5 @@ +# cw-payroll-factory + +Serves as a factory that instantiates [cw-vesting](../cw-vesting) contracts and stores them in an indexed maps for easy querying by recipient or the instantiator (i.e. give me all of my vesting payment contracts or give me all of a DAO's vesting payment contracts). + +An optional `owner` can be specified when instantiating `cw-payroll-factory` that limits contract instantiation to a single account. diff --git a/contracts/external/cw-payroll-factory/examples/schema.rs b/contracts/external/cw-payroll-factory/examples/schema.rs new file mode 100644 index 000000000..bd3ccb530 --- /dev/null +++ b/contracts/external/cw-payroll-factory/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use cw_payroll_factory::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json b/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json new file mode 100644 index 000000000..f8faac397 --- /dev/null +++ b/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json @@ -0,0 +1,940 @@ +{ + "contract_name": "cw-payroll-factory", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "vesting_code_id" + ], + "properties": { + "owner": { + "type": [ + "string", + "null" + ] + }, + "vesting_code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Instantiates a new vesting contract that is funded by a cw20 token.", + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new vesting contract that is funded by a native token.", + "type": "object", + "required": [ + "instantiate_native_payroll_contract" + ], + "properties": { + "instantiate_native_payroll_contract": { + "type": "object", + "required": [ + "instantiate_msg", + "label" + ], + "properties": { + "instantiate_msg": { + "$ref": "#/definitions/InstantiateMsg" + }, + "label": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Callable only by the current owner. Updates the code ID used while instantiating vesting contracts.", + "type": "object", + "required": [ + "update_code_id" + ], + "properties": { + "update_code_id": { + "type": "object", + "required": [ + "vesting_code_id" + ], + "properties": { + "vesting_code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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" + ] + } + ] + }, + "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 + }, + "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 + } + ] + }, + "InstantiateMsg": { + "type": "object", + "required": [ + "denom", + "recipient", + "schedule", + "title", + "total", + "unbonding_duration_seconds", + "vesting_duration_seconds" + ], + "properties": { + "denom": { + "description": "The type and denom of token being vested.", + "allOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + } + ] + }, + "description": { + "description": "A description for the payment to provide more context.", + "type": [ + "string", + "null" + ] + }, + "owner": { + "description": "The optional owner address of the contract. If an owner is specified, the owner may cancel the vesting contract at any time and withdraw unvested funds.", + "type": [ + "string", + "null" + ] + }, + "recipient": { + "description": "The receiver address of the vesting tokens.", + "type": "string" + }, + "schedule": { + "description": "The vesting schedule, can be either `SaturatingLinear` vesting (which vests evenly over time), or `PiecewiseLinear` which can represent a more complicated vesting schedule.", + "allOf": [ + { + "$ref": "#/definitions/Schedule" + } + ] + }, + "start_time": { + "description": "The time to start vesting, or None to start vesting when the contract is instantiated. `start_time` may be in the past, though the contract checks that `start_time + vesting_duration_seconds > now`. Otherwise, this would amount to a regular fund transfer.", + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + }, + "title": { + "description": "The a name or title for this payment.", + "type": "string" + }, + "total": { + "description": "The total amount of tokens to be vested.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "unbonding_duration_seconds": { + "description": "The unbonding duration for the chain this contract is deployed on. Smart contracts do not have access to this data as stargate queries are disabled on most chains, and cosmwasm-std provides no way to query it.\n\nThis value being too high will cause this contract to hold funds for longer than needed, this value being too low will reduce the quality of error messages and require additional external calculations with correct values to withdraw avaliable funds from the contract.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vesting_duration_seconds": { + "description": "The length of the vesting schedule in seconds. Must be non-zero, though one second vesting durations are allowed. This may be combined with a `start_time` in the future to create an agreement that instantly vests at a time in the future, and allows the receiver to stake vesting tokens before the agreement completes.\n\nSee `suite_tests/tests.rs` `test_almost_instavest_in_the_future` for an example of this.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "Schedule": { + "oneOf": [ + { + "description": "Vests linearally from `0` to `total`.", + "type": "string", + "enum": [ + "saturating_linear" + ] + }, + { + "description": "Vests by linearally interpolating between the provided (seconds, amount) points. The first amount must be zero and the last amount the total vesting amount. `seconds` are seconds since the vest start time.\n\nThere is a problem in the underlying Curve library that doesn't allow zero start values, so the first value of `seconds` must be > 1. To start at a particular time (if you need that level of percision), subtract one from the true start time, and make the first `seconds` value `1`.\n\n", + "type": "object", + "required": [ + "piecewise_linear" + ], + "properties": { + "piecewise_linear": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + { + "$ref": "#/definitions/Uint128" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + }, + "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": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Returns list of all vesting payment contracts", + "type": "object", + "required": [ + "list_vesting_contracts" + ], + "properties": { + "list_vesting_contracts": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of all vesting payment contracts in reverse", + "type": "object", + "required": [ + "list_vesting_contracts_reverse" + ], + "properties": { + "list_vesting_contracts_reverse": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_before": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of all vesting payment contracts by who instantiated them", + "type": "object", + "required": [ + "list_vesting_contracts_by_instantiator" + ], + "properties": { + "list_vesting_contracts_by_instantiator": { + "type": "object", + "required": [ + "instantiator" + ], + "properties": { + "instantiator": { + "type": "string" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of all vesting payment contracts by who instantiated them in reverse", + "type": "object", + "required": [ + "list_vesting_contracts_by_instantiator_reverse" + ], + "properties": { + "list_vesting_contracts_by_instantiator_reverse": { + "type": "object", + "required": [ + "instantiator" + ], + "properties": { + "instantiator": { + "type": "string" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_before": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of all vesting payment contracts by recipient", + "type": "object", + "required": [ + "list_vesting_contracts_by_recipient" + ], + "properties": { + "list_vesting_contracts_by_recipient": { + "type": "object", + "required": [ + "recipient" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "recipient": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of all vesting payment contracts by recipient in reverse", + "type": "object", + "required": [ + "list_vesting_contracts_by_recipient_reverse" + ], + "properties": { + "list_vesting_contracts_by_recipient_reverse": { + "type": "object", + "required": [ + "recipient" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "recipient": { + "type": "string" + }, + "start_before": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns info about the contract ownership, if set", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the code ID currently being used to instantiate vesting contracts.", + "type": "object", + "required": [ + "code_id" + ], + "properties": { + "code_id": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": null, + "sudo": null, + "responses": { + "code_id": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "list_vesting_contracts": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_VestingContract", + "type": "array", + "items": { + "$ref": "#/definitions/VestingContract" + }, + "definitions": { + "VestingContract": { + "type": "object", + "required": [ + "contract", + "instantiator", + "recipient" + ], + "properties": { + "contract": { + "type": "string" + }, + "instantiator": { + "type": "string" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_vesting_contracts_by_instantiator": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_VestingContract", + "type": "array", + "items": { + "$ref": "#/definitions/VestingContract" + }, + "definitions": { + "VestingContract": { + "type": "object", + "required": [ + "contract", + "instantiator", + "recipient" + ], + "properties": { + "contract": { + "type": "string" + }, + "instantiator": { + "type": "string" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_vesting_contracts_by_instantiator_reverse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_VestingContract", + "type": "array", + "items": { + "$ref": "#/definitions/VestingContract" + }, + "definitions": { + "VestingContract": { + "type": "object", + "required": [ + "contract", + "instantiator", + "recipient" + ], + "properties": { + "contract": { + "type": "string" + }, + "instantiator": { + "type": "string" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_vesting_contracts_by_recipient": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_VestingContract", + "type": "array", + "items": { + "$ref": "#/definitions/VestingContract" + }, + "definitions": { + "VestingContract": { + "type": "object", + "required": [ + "contract", + "instantiator", + "recipient" + ], + "properties": { + "contract": { + "type": "string" + }, + "instantiator": { + "type": "string" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_vesting_contracts_by_recipient_reverse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_VestingContract", + "type": "array", + "items": { + "$ref": "#/definitions/VestingContract" + }, + "definitions": { + "VestingContract": { + "type": "object", + "required": [ + "contract", + "instantiator", + "recipient" + ], + "properties": { + "contract": { + "type": "string" + }, + "instantiator": { + "type": "string" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_vesting_contracts_reverse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_VestingContract", + "type": "array", + "items": { + "$ref": "#/definitions/VestingContract" + }, + "definitions": { + "VestingContract": { + "type": "object", + "required": [ + "contract", + "instantiator", + "recipient" + ], + "properties": { + "contract": { + "type": "string" + }, + "instantiator": { + "type": "string" + }, + "recipient": { + "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" + } + } + } + } +} diff --git a/contracts/external/cw-payroll-factory/src/contract.rs b/contracts/external/cw-payroll-factory/src/contract.rs new file mode 100644 index 000000000..aa7af2b65 --- /dev/null +++ b/contracts/external/cw-payroll-factory/src/contract.rs @@ -0,0 +1,358 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + from_binary, to_binary, Binary, CosmosMsg, Deps, DepsMut, Env, MessageInfo, Order, Reply, + Response, StdResult, SubMsg, WasmMsg, +}; +use cosmwasm_std::{Addr, Coin}; + +use cw2::set_contract_version; +use cw20::Cw20ExecuteMsg; +use cw20::Cw20ReceiveMsg; +use cw_denom::CheckedDenom; +use cw_storage_plus::Bound; +use cw_utils::{nonpayable, parse_reply_instantiate_data}; +use cw_vesting::msg::{ + InstantiateMsg as PayrollInstantiateMsg, QueryMsg as PayrollQueryMsg, + ReceiveMsg as PayrollReceiveMsg, +}; +use cw_vesting::vesting::Vest; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, ReceiveMsg}; +use crate::state::{vesting_contracts, VestingContract, TMP_INSTANTIATOR_INFO, VESTING_CODE_ID}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:cw-payroll-factory"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const INSTANTIATE_CONTRACT_REPLY_ID: u64 = 0; +pub const DEFAULT_LIMIT: u32 = 10; +pub const MAX_LIMIT: u32 = 50; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + VESTING_CODE_ID.save(deps.storage, &msg.vesting_code_id)?; + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("creator", info.sender)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Receive(msg) => execute_receive_cw20(env, deps, info, msg), + ExecuteMsg::InstantiateNativePayrollContract { + instantiate_msg, + label, + } => execute_instantiate_native_payroll_contract(deps, info, instantiate_msg, label), + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + ExecuteMsg::UpdateCodeId { vesting_code_id } => { + execute_update_code_id(deps, info, vesting_code_id) + } + } +} + +pub fn execute_receive_cw20( + _env: Env, + deps: DepsMut, + info: MessageInfo, + receive_msg: Cw20ReceiveMsg, +) -> Result { + // Only accepts cw20 tokens + nonpayable(&info)?; + + let msg: ReceiveMsg = from_binary(&receive_msg.msg)?; + + if TMP_INSTANTIATOR_INFO.may_load(deps.storage)?.is_some() { + return Err(ContractError::Reentrancy); + } + + // Save instantiator info for use in reply (cw20 sender in this case) + let sender = deps.api.addr_validate(&receive_msg.sender)?; + TMP_INSTANTIATOR_INFO.save(deps.storage, &sender)?; + + match msg { + ReceiveMsg::InstantiatePayrollContract { + instantiate_msg, + label, + } => { + if receive_msg.amount != instantiate_msg.total { + return Err(ContractError::WrongFundAmount { + sent: receive_msg.amount, + expected: instantiate_msg.total, + }); + } + instantiate_contract(deps, sender, None, instantiate_msg, label) + } + } +} + +pub fn execute_instantiate_native_payroll_contract( + deps: DepsMut, + info: MessageInfo, + instantiate_msg: PayrollInstantiateMsg, + label: String, +) -> Result { + // Save instantiator info for use in reply + TMP_INSTANTIATOR_INFO.save(deps.storage, &info.sender)?; + + instantiate_contract(deps, info.sender, Some(info.funds), instantiate_msg, label) +} + +/// `sender` here refers to the initiator of the vesting, not the +/// literal sender of the message. Practically speaking, this means +/// that it should be set to the sender of the cw20's being vested, +/// and not the cw20 contract when dealing with non-native vesting. +pub fn instantiate_contract( + deps: DepsMut, + sender: Addr, + funds: Option>, + instantiate_msg: PayrollInstantiateMsg, + label: String, +) -> Result { + // Check sender is contract owner if set + let ownership = cw_ownable::get_ownership(deps.storage)?; + if ownership + .owner + .as_ref() + .map_or(false, |owner| *owner != sender) + { + return Err(ContractError::Unauthorized {}); + } + // No owner is always allowed. If an owner is specified, it must + // exactly match the owner of the contract. + if instantiate_msg.owner.as_deref().map_or(false, |i| { + ownership.owner.as_ref().map_or(true, |o| o.as_str() != i) + }) { + return Err(ContractError::OwnerMissmatch { + actual: instantiate_msg.owner, + expected: ownership.owner.map(|a| a.into_string()), + }); + } + + let code_id = VESTING_CODE_ID.load(deps.storage)?; + + // Instantiate the specified contract with owner as the admin. + let instantiate = WasmMsg::Instantiate { + admin: instantiate_msg.owner.clone(), + code_id, + msg: to_binary(&instantiate_msg)?, + funds: funds.unwrap_or_default(), + label, + }; + + let msg = SubMsg::reply_on_success(instantiate, INSTANTIATE_CONTRACT_REPLY_ID); + + Ok(Response::default() + .add_attribute("action", "instantiate_cw_vesting") + .add_submessage(msg)) +} + +pub fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +pub fn execute_update_code_id( + deps: DepsMut, + info: MessageInfo, + vesting_code_id: u64, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + VESTING_CODE_ID.save(deps.storage, &vesting_code_id)?; + Ok(Response::default() + .add_attribute("action", "update_code_id") + .add_attribute("vesting_code_id", vesting_code_id.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::ListVestingContracts { start_after, limit } => { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.as_deref().map(Bound::exclusive); + + let res: Vec = vesting_contracts() + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .flat_map(|vc| Ok::(vc?.1)) + .collect(); + + Ok(to_binary(&res)?) + } + QueryMsg::ListVestingContractsReverse { + start_before, + limit, + } => { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_before.as_deref().map(Bound::exclusive); + + let res: Vec = vesting_contracts() + .range(deps.storage, None, start, Order::Descending) + .take(limit) + .flat_map(|vc| Ok::(vc?.1)) + .collect(); + + Ok(to_binary(&res)?) + } + QueryMsg::ListVestingContractsByInstantiator { + instantiator, + start_after, + limit, + } => { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::::exclusive); + + // Validate owner address + deps.api.addr_validate(&instantiator)?; + + let res: Vec = vesting_contracts() + .idx + .instantiator + .prefix(instantiator) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .flat_map(|vc| Ok::(vc?.1)) + .collect(); + + Ok(to_binary(&res)?) + } + QueryMsg::ListVestingContractsByInstantiatorReverse { + instantiator, + start_before, + limit, + } => { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_before.map(Bound::::exclusive); + + // Validate owner address + deps.api.addr_validate(&instantiator)?; + + let res: Vec = vesting_contracts() + .idx + .instantiator + .prefix(instantiator) + .range(deps.storage, None, start, Order::Descending) + .take(limit) + .flat_map(|vc| Ok::(vc?.1)) + .collect(); + + Ok(to_binary(&res)?) + } + QueryMsg::ListVestingContractsByRecipient { + recipient, + start_after, + limit, + } => { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::::exclusive); + + // Validate recipient address + deps.api.addr_validate(&recipient)?; + + let res: Vec = vesting_contracts() + .idx + .recipient + .prefix(recipient) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .flat_map(|vc| Ok::(vc?.1)) + .collect(); + + Ok(to_binary(&res)?) + } + QueryMsg::ListVestingContractsByRecipientReverse { + recipient, + start_before, + limit, + } => { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_before.map(Bound::::exclusive); + + // Validate recipient address + deps.api.addr_validate(&recipient)?; + + let res: Vec = vesting_contracts() + .idx + .recipient + .prefix(recipient) + .range(deps.storage, None, start, Order::Descending) + .take(limit) + .flat_map(|vc| Ok::(vc?.1)) + .collect(); + + Ok(to_binary(&res)?) + } + QueryMsg::Ownership {} => to_binary(&cw_ownable::get_ownership(deps.storage)?), + QueryMsg::CodeId {} => to_binary(&VESTING_CODE_ID.load(deps.storage)?), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + INSTANTIATE_CONTRACT_REPLY_ID => { + let res = parse_reply_instantiate_data(msg)?; + let contract_addr = deps.api.addr_validate(&res.contract_address)?; + + // Query new vesting payment contract for info + let vest: Vest = deps + .querier + .query_wasm_smart(contract_addr.clone(), &PayrollQueryMsg::Info {})?; + + let instantiator = TMP_INSTANTIATOR_INFO.load(deps.storage)?; + + // Save vesting contract payment info + vesting_contracts().save( + deps.storage, + contract_addr.as_ref(), + &VestingContract { + instantiator: instantiator.to_string(), + recipient: vest.recipient.to_string(), + contract: contract_addr.to_string(), + }, + )?; + + // Clear tmp instatiator info + TMP_INSTANTIATOR_INFO.remove(deps.storage); + + // If cw20, fire off fund message! + let msgs: Vec = match vest.denom { + CheckedDenom::Native(_) => vec![], + CheckedDenom::Cw20(ref denom) => { + // Send transaction to fund contract + vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: denom.to_string(), + msg: to_binary(&Cw20ExecuteMsg::Send { + contract: contract_addr.to_string(), + amount: vest.total(), + msg: to_binary(&PayrollReceiveMsg::Fund {})?, + })?, + funds: vec![], + })] + } + }; + + Ok(Response::default() + .add_attribute("new_payroll_contract", contract_addr) + .add_messages(msgs)) + } + _ => Err(ContractError::UnknownReplyId { id: msg.id }), + } +} diff --git a/contracts/external/cw-payroll-factory/src/error.rs b/contracts/external/cw-payroll-factory/src/error.rs new file mode 100644 index 000000000..f90677a27 --- /dev/null +++ b/contracts/external/cw-payroll-factory/src/error.rs @@ -0,0 +1,37 @@ +use cosmwasm_std::{StdError, Uint128}; +use cw_ownable::OwnershipError; +use cw_utils::{ParseReplyError, PaymentError}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error(transparent)] + Ownable(#[from] OwnershipError), + + #[error("{0}")] + PaymentError(#[from] PaymentError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("instantiate message owner does not match factory owner. got ({actual:?}) expected ({expected:?})")] + OwnerMissmatch { + actual: Option, + expected: Option, + }, + + #[error("{0}")] + ParseReplyError(#[from] ParseReplyError), + + #[error("Got a submessage reply with unknown id: {id}")] + UnknownReplyId { id: u64 }, + + #[error("reentered factory during payroll instantiation")] + Reentrancy, + + #[error("vesting contract vests ({expected}) tokens, funded with ({sent})")] + WrongFundAmount { sent: Uint128, expected: Uint128 }, +} diff --git a/contracts/external/cw-payroll-factory/src/lib.rs b/contracts/external/cw-payroll-factory/src/lib.rs new file mode 100644 index 000000000..254bec4fe --- /dev/null +++ b/contracts/external/cw-payroll-factory/src/lib.rs @@ -0,0 +1,14 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +// so that consumers don't need a cw_ownable dependency to consume this contract's queries. +pub use cw_ownable::Ownership; + +pub use crate::error::ContractError; diff --git a/contracts/external/cw-payroll-factory/src/msg.rs b/contracts/external/cw-payroll-factory/src/msg.rs new file mode 100644 index 000000000..eacc5357e --- /dev/null +++ b/contracts/external/cw-payroll-factory/src/msg.rs @@ -0,0 +1,88 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cw20::Cw20ReceiveMsg; +use cw_ownable::cw_ownable_execute; +use cw_vesting::msg::InstantiateMsg as PayrollInstantiateMsg; + +#[cw_serde] +pub struct InstantiateMsg { + pub owner: Option, + pub vesting_code_id: u64, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + /// Instantiates a new vesting contract that is funded by a cw20 token. + Receive(Cw20ReceiveMsg), + /// Instantiates a new vesting contract that is funded by a native token. + InstantiateNativePayrollContract { + instantiate_msg: PayrollInstantiateMsg, + label: String, + }, + + /// Callable only by the current owner. Updates the code ID used + /// while instantiating vesting contracts. + UpdateCodeId { vesting_code_id: u64 }, +} + +// Receiver setup +#[cw_serde] +pub enum ReceiveMsg { + /// Funds a vesting contract with a cw20 token + InstantiatePayrollContract { + instantiate_msg: PayrollInstantiateMsg, + label: String, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns list of all vesting payment contracts + #[returns(Vec)] + ListVestingContracts { + start_after: Option, + limit: Option, + }, + /// Returns list of all vesting payment contracts in reverse + #[returns(Vec)] + ListVestingContractsReverse { + start_before: Option, + limit: Option, + }, + /// Returns list of all vesting payment contracts by who instantiated them + #[returns(Vec)] + ListVestingContractsByInstantiator { + instantiator: String, + start_after: Option, + limit: Option, + }, + /// Returns list of all vesting payment contracts by who instantiated them in reverse + #[returns(Vec)] + ListVestingContractsByInstantiatorReverse { + instantiator: String, + start_before: Option, + limit: Option, + }, + /// Returns list of all vesting payment contracts by recipient + #[returns(Vec)] + ListVestingContractsByRecipient { + recipient: String, + start_after: Option, + limit: Option, + }, + /// Returns list of all vesting payment contracts by recipient in reverse + #[returns(Vec)] + ListVestingContractsByRecipientReverse { + recipient: String, + start_before: Option, + limit: Option, + }, + /// Returns info about the contract ownership, if set + #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] + Ownership {}, + + /// Returns the code ID currently being used to instantiate vesting contracts. + #[returns(::std::primitive::u64)] + CodeId {}, +} diff --git a/contracts/external/cw-payroll-factory/src/state.rs b/contracts/external/cw-payroll-factory/src/state.rs new file mode 100644 index 000000000..c65514faa --- /dev/null +++ b/contracts/external/cw-payroll-factory/src/state.rs @@ -0,0 +1,42 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, MultiIndex}; + +/// Temporarily holds the address of the instantiator for use in submessages +pub const TMP_INSTANTIATOR_INFO: Item = Item::new("tmp_instantiator_info"); +pub const VESTING_CODE_ID: Item = Item::new("pci"); + +#[cw_serde] +pub struct VestingContract { + pub contract: String, + pub instantiator: String, + pub recipient: String, +} + +pub struct TokenIndexes<'a> { + pub instantiator: MultiIndex<'a, String, VestingContract, String>, + pub recipient: MultiIndex<'a, String, VestingContract, String>, +} + +impl<'a> IndexList for TokenIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.instantiator, &self.recipient]; + Box::new(v.into_iter()) + } +} + +pub fn vesting_contracts<'a>() -> IndexedMap<'a, &'a str, VestingContract, TokenIndexes<'a>> { + let indexes = TokenIndexes { + instantiator: MultiIndex::new( + |_pk: &[u8], d: &VestingContract| d.instantiator.clone(), + "vesting_contracts", + "vesting_contracts__instantiator", + ), + recipient: MultiIndex::new( + |_pk: &[u8], d: &VestingContract| d.recipient.clone(), + "vesting_contracts", + "vesting_contracts__recipient", + ), + }; + IndexedMap::new("vesting_contracts", indexes) +} diff --git a/contracts/external/cw-payroll-factory/src/tests.rs b/contracts/external/cw-payroll-factory/src/tests.rs new file mode 100644 index 000000000..9245fbad0 --- /dev/null +++ b/contracts/external/cw-payroll-factory/src/tests.rs @@ -0,0 +1,720 @@ +use cosmwasm_std::{coins, to_binary, Addr, Empty, Uint128}; +use cw20::{Cw20Coin, Cw20ExecuteMsg}; +use cw_denom::UncheckedDenom; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; +use cw_ownable::OwnershipError; +use cw_vesting::{ + msg::{InstantiateMsg as PayrollInstantiateMsg, QueryMsg as PayrollQueryMsg}, + vesting::{Schedule, Status, Vest}, +}; + +use crate::{ + msg::{ExecuteMsg, InstantiateMsg, QueryMsg, ReceiveMsg}, + state::VestingContract, + ContractError, +}; + +const ALICE: &str = "alice"; +const BOB: &str = "bob"; +const INITIAL_BALANCE: u128 = 1000000000; +const NATIVE_DENOM: &str = "denom"; + +fn factory_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply); + Box::new(contract) +} + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +pub fn cw_vesting_contract() -> Box> { + let contract = ContractWrapper::new( + cw_vesting::contract::execute, + cw_vesting::contract::instantiate, + cw_vesting::contract::query, + ); + Box::new(contract) +} + +#[test] +pub fn test_instantiate_native_payroll_contract() { + let mut app = App::default(); + let code_id = app.store_code(factory_contract()); + let cw_vesting_code_id = app.store_code(cw_vesting_contract()); + + // Instantiate factory with only Alice allowed to instantiate payroll contracts + let instantiate = InstantiateMsg { + owner: Some(ALICE.to_string()), + vesting_code_id: cw_vesting_code_id, + }; + let factory_addr = app + .instantiate_contract( + code_id, + Addr::unchecked("CREATOR"), + &instantiate, + &[], + "cw-admin-factory", + None, + ) + .unwrap(); + + // Mint alice and bob native tokens + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: ALICE.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: BOB.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + + let amount = Uint128::new(1000000); + let unchecked_denom = UncheckedDenom::Native(NATIVE_DENOM.to_string()); + + let instantiate_payroll_msg = ExecuteMsg::InstantiateNativePayrollContract { + instantiate_msg: PayrollInstantiateMsg { + owner: Some(ALICE.to_string()), + recipient: BOB.to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: amount, + denom: unchecked_denom, + schedule: Schedule::SaturatingLinear, + vesting_duration_seconds: 200, + unbonding_duration_seconds: 2592000, // 30 days + start_time: None, + }, + label: "Payroll".to_string(), + }; + + let res = app + .execute_contract( + Addr::unchecked(ALICE), + factory_addr.clone(), + &instantiate_payroll_msg, + &coins(amount.into(), NATIVE_DENOM), + ) + .unwrap(); + + // BOB can't instantiate as owner is configured + let err: ContractError = app + .execute_contract( + Addr::unchecked(BOB), + factory_addr.clone(), + &instantiate_payroll_msg, + &coins(amount.into(), NATIVE_DENOM), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Get the payroll address from the instantiate event + let instantiate_event = &res.events[2]; + assert_eq!(instantiate_event.ty, "instantiate"); + let cw_vesting_addr = instantiate_event.attributes[0].value.clone(); + + // Check that admin of contract is owner specified in Instantiation Message + let contract_info = app + .wrap() + .query_wasm_contract_info(cw_vesting_addr) + .unwrap(); + assert_eq!(contract_info.admin, Some(ALICE.to_string())); + + // Test query list of contracts + let contracts: Vec = app + .wrap() + .query_wasm_smart( + factory_addr.clone(), + &QueryMsg::ListVestingContracts { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(contracts.len(), 1); + + // Test query by instantiator + let contracts: Vec = app + .wrap() + .query_wasm_smart( + factory_addr.clone(), + &QueryMsg::ListVestingContractsByInstantiator { + instantiator: ALICE.to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(contracts.len(), 1); + + // Test query by instantiator with no results + let contracts: Vec = app + .wrap() + .query_wasm_smart( + factory_addr.clone(), + &QueryMsg::ListVestingContractsByInstantiator { + instantiator: BOB.to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(contracts.len(), 0); + + // Test query by recipient + let contracts: Vec = app + .wrap() + .query_wasm_smart( + factory_addr.clone(), + &QueryMsg::ListVestingContractsByRecipient { + recipient: BOB.to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(contracts.len(), 1); + + // Test query by recipient no results + let contracts: Vec = app + .wrap() + .query_wasm_smart( + factory_addr, + &QueryMsg::ListVestingContractsByRecipient { + recipient: ALICE.to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(contracts.len(), 0); +} + +#[test] +pub fn test_instantiate_cw20_payroll_contract() { + let mut app = App::default(); + let code_id = app.store_code(factory_contract()); + let cw20_code_id = app.store_code(cw20_contract()); + let cw_vesting_code_id = app.store_code(cw_vesting_contract()); + + // Instantiate cw20 contract with balances for Alice + let cw20_addr = app + .instantiate_contract( + cw20_code_id, + Addr::unchecked(ALICE), + &cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: ALICE.to_string(), + amount: Uint128::new(INITIAL_BALANCE), + }], + mint: None, + marketing: None, + }, + &[], + "cw20-base", + None, + ) + .unwrap(); + + let instantiate = InstantiateMsg { + owner: Some(ALICE.to_string()), + vesting_code_id: cw_vesting_code_id, + }; + let factory_addr = app + .instantiate_contract( + code_id, + Addr::unchecked("CREATOR"), + &instantiate, + &[], + "cw-admin-factory", + None, + ) + .unwrap(); + + // Mint alice native tokens + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: ALICE.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + + let amount = Uint128::new(1000000); + let unchecked_denom = UncheckedDenom::Cw20(cw20_addr.to_string()); + + let instantiate_payroll_msg = PayrollInstantiateMsg { + owner: Some(ALICE.to_string()), + recipient: BOB.to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: amount, + denom: unchecked_denom, + schedule: Schedule::SaturatingLinear, + vesting_duration_seconds: 200, + unbonding_duration_seconds: 2592000, // 30 days + start_time: None, + }; + + // Attempting to call InstantiatePayrollContract directly with cw20 fails + app.execute_contract( + Addr::unchecked(ALICE), + factory_addr.clone(), + &ExecuteMsg::InstantiateNativePayrollContract { + instantiate_msg: instantiate_payroll_msg.clone(), + label: "Payroll".to_string(), + }, + &coins(amount.into(), NATIVE_DENOM), + ) + .unwrap_err(); + + let res = app + .execute_contract( + Addr::unchecked(ALICE), + cw20_addr, + &Cw20ExecuteMsg::Send { + contract: factory_addr.to_string(), + amount: instantiate_payroll_msg.total, + msg: to_binary(&ReceiveMsg::InstantiatePayrollContract { + instantiate_msg: instantiate_payroll_msg, + label: "Payroll".to_string(), + }) + .unwrap(), + }, + &coins(amount.into(), NATIVE_DENOM), + ) + .unwrap(); + + // Get the payroll address from the instantiate event + let instantiate_event = &res.events[4]; + assert_eq!(instantiate_event.ty, "instantiate"); + let cw_vesting_addr = instantiate_event.attributes[0].value.clone(); + + // Check that admin of contract is owner specified in Instantiation Message + let contract_info = app + .wrap() + .query_wasm_contract_info(cw_vesting_addr.clone()) + .unwrap(); + assert_eq!(contract_info.admin, Some(ALICE.to_string())); + + // Test query by instantiator + let contracts: Vec = app + .wrap() + .query_wasm_smart( + factory_addr, + &QueryMsg::ListVestingContractsByInstantiator { + instantiator: ALICE.to_string(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(contracts.len(), 1); + + // Check that the vesting payment contract is active + let vp: Vest = app + .wrap() + .query_wasm_smart(cw_vesting_addr, &PayrollQueryMsg::Info {}) + .unwrap(); + assert_eq!(vp.status, Status::Funded); +} + +#[test] +fn test_instantiate_wrong_ownership_native() { + let mut app = App::default(); + let code_id = app.store_code(factory_contract()); + let cw_vesting_code_id = app.store_code(cw_vesting_contract()); + + let amount = Uint128::new(1000000); + let unchecked_denom = UncheckedDenom::Native(NATIVE_DENOM.to_string()); + + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: "ekez".to_string(), + amount: coins(amount.u128() * 2, NATIVE_DENOM), + } + })) + .unwrap(); + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: ALICE.to_string(), + amount: coins(amount.u128() * 2, NATIVE_DENOM), + } + })) + .unwrap(); + + // Alice is the owner. Contracts are only allowed if their owner + // is alice or none and the sender is alice. + let instantiate = InstantiateMsg { + owner: Some(ALICE.to_string()), + vesting_code_id: cw_vesting_code_id, + }; + let factory_addr = app + .instantiate_contract( + code_id, + Addr::unchecked("CREATOR"), + &instantiate, + &[], + "cw-admin-factory", + None, + ) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(ALICE), + factory_addr.clone(), + &ExecuteMsg::InstantiateNativePayrollContract { + instantiate_msg: PayrollInstantiateMsg { + owner: Some("ekez".to_string()), + recipient: BOB.to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: amount, + denom: unchecked_denom.clone(), + schedule: Schedule::SaturatingLinear, + vesting_duration_seconds: 200, + unbonding_duration_seconds: 2592000, // 30 days + start_time: None, + }, + label: "vesting".to_string(), + }, + &coins(amount.u128(), NATIVE_DENOM), + ) + .unwrap_err() + .downcast() + .unwrap(); + + // Can't instantiate with an owner who is not the factory owner. + assert_eq!( + err, + ContractError::OwnerMissmatch { + actual: Some("ekez".to_string()), + expected: Some(ALICE.to_string()) + } + ); + + let err: ContractError = app + .execute_contract( + Addr::unchecked("ekez"), + factory_addr, + &ExecuteMsg::InstantiateNativePayrollContract { + instantiate_msg: PayrollInstantiateMsg { + owner: Some(ALICE.to_string()), + recipient: BOB.to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: amount, + denom: unchecked_denom, + schedule: Schedule::SaturatingLinear, + vesting_duration_seconds: 200, + unbonding_duration_seconds: 2592000, // 30 days + start_time: None, + }, + label: "vesting".to_string(), + }, + &coins(amount.u128(), NATIVE_DENOM), + ) + .unwrap_err() + .downcast() + .unwrap(); + + // Can't instantiate if you are not the owner. + assert_eq!(err, ContractError::Unauthorized {}); +} + +#[test] +fn test_instantiate_wrong_owner_cw20() { + let mut app = App::default(); + let code_id = app.store_code(factory_contract()); + let cw20_code_id = app.store_code(cw20_contract()); + let cw_vesting_code_id = app.store_code(cw_vesting_contract()); + + let cw20_addr = app + .instantiate_contract( + cw20_code_id, + Addr::unchecked(ALICE), + &cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: ALICE.to_string(), + amount: Uint128::new(INITIAL_BALANCE), + }], + mint: None, + marketing: None, + }, + &[], + "cw20-base", + None, + ) + .unwrap(); + + let instantiate = InstantiateMsg { + owner: Some(ALICE.to_string()), + vesting_code_id: cw_vesting_code_id, + }; + let factory_addr = app + .instantiate_contract( + code_id, + Addr::unchecked("CREATOR"), + &instantiate, + &[], + "cw-admin-factory", + None, + ) + .unwrap(); + + let amount = Uint128::new(1000000); + let unchecked_denom = UncheckedDenom::Cw20(cw20_addr.to_string()); + + let instantiate_payroll_msg = PayrollInstantiateMsg { + owner: Some(BOB.to_string()), + recipient: BOB.to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: amount, + denom: unchecked_denom, + schedule: Schedule::SaturatingLinear, + vesting_duration_seconds: 200, + unbonding_duration_seconds: 2592000, // 30 days + start_time: None, + }; + + let err: ContractError = app + .execute_contract( + Addr::unchecked(ALICE), + cw20_addr, + &Cw20ExecuteMsg::Send { + contract: factory_addr.to_string(), + amount: instantiate_payroll_msg.total, + msg: to_binary(&ReceiveMsg::InstantiatePayrollContract { + instantiate_msg: instantiate_payroll_msg, + label: "Payroll".to_string(), + }) + .unwrap(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::OwnerMissmatch { + actual: Some(BOB.to_string()), + expected: Some(ALICE.to_string()) + } + ) +} + +#[test] +fn test_update_vesting_code_id() { + let mut app = App::default(); + let code_id = app.store_code(factory_contract()); + let cw_vesting_code_id = app.store_code(cw_vesting_contract()); + let cw_vesting_code_two = app.store_code(cw_vesting_contract()); + + // Instantiate factory with only Alice allowed to instantiate payroll contracts + let instantiate = InstantiateMsg { + owner: Some(ALICE.to_string()), + vesting_code_id: cw_vesting_code_id, + }; + let factory_addr = app + .instantiate_contract( + code_id, + Addr::unchecked("CREATOR"), + &instantiate, + &[], + "cw-admin-factory", + None, + ) + .unwrap(); + + // Update the code ID to a new one. + app.execute_contract( + Addr::unchecked(ALICE), + factory_addr.clone(), + &ExecuteMsg::UpdateCodeId { + vesting_code_id: cw_vesting_code_two, + }, + &[], + ) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(BOB), + factory_addr.clone(), + &ExecuteMsg::UpdateCodeId { + vesting_code_id: cw_vesting_code_two, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Ownable(OwnershipError::NotOwner)); + + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: ALICE.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + + let amount = Uint128::new(1000000); + let unchecked_denom = UncheckedDenom::Native(NATIVE_DENOM.to_string()); + + let instantiate_payroll_msg = ExecuteMsg::InstantiateNativePayrollContract { + instantiate_msg: PayrollInstantiateMsg { + owner: Some(ALICE.to_string()), + recipient: BOB.to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: amount, + denom: unchecked_denom, + schedule: Schedule::SaturatingLinear, + vesting_duration_seconds: 200, + unbonding_duration_seconds: 2592000, // 30 days + start_time: None, + }, + label: "Payroll".to_string(), + }; + + let res = app + .execute_contract( + Addr::unchecked(ALICE), + factory_addr, + &instantiate_payroll_msg, + &coins(amount.into(), NATIVE_DENOM), + ) + .unwrap(); + + // Check that the contract was instantiated using the new code ID. + let instantiate_event = &res.events[2]; + assert_eq!(instantiate_event.ty, "instantiate"); + let cw_vesting_addr = instantiate_event.attributes[0].value.clone(); + let info = app + .wrap() + .query_wasm_contract_info(cw_vesting_addr) + .unwrap(); + assert_eq!(info.code_id, cw_vesting_code_two); +} + +/// This test was contributed by Oak Security as part of their audit +/// of cw-vesting. It addresses issue two, "Misconfiguring the total +/// vested amount to be lower than the sent CW20 amount would cause a +/// loss of funds". +#[test] +pub fn test_inconsistent_cw20_amount() { + let mut app = App::default(); + let code_id = app.store_code(factory_contract()); + let cw20_code_id = app.store_code(cw20_contract()); + let cw_vesting_code_id = app.store_code(cw_vesting_contract()); + // Instantiate cw20 contract with balances for Alice + let cw20_addr = app + .instantiate_contract( + cw20_code_id, + Addr::unchecked(ALICE), + &cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: ALICE.to_string(), + amount: Uint128::new(INITIAL_BALANCE), + }], + mint: None, + marketing: None, + }, + &[], + "cw20-base", + None, + ) + .unwrap(); + let instantiate = InstantiateMsg { + owner: Some(ALICE.to_string()), + vesting_code_id: cw_vesting_code_id, + }; + let factory_addr = app + .instantiate_contract( + code_id, + Addr::unchecked("CREATOR"), + &instantiate, + &[], + "cw-admin-factory", + None, + ) + .unwrap(); + // Mint alice native tokens + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: ALICE.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + let amount = Uint128::new(1000000); + let unchecked_denom = UncheckedDenom::Cw20(cw20_addr.to_string()); + let instantiate_payroll_msg = PayrollInstantiateMsg { + owner: Some(ALICE.to_string()), + recipient: BOB.to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: amount - Uint128::new(1), // lesser amount than sent + denom: unchecked_denom, + schedule: Schedule::SaturatingLinear, + vesting_duration_seconds: 200, + unbonding_duration_seconds: 2592000, // 30 days + start_time: None, + }; + let err: ContractError = app + .execute_contract( + Addr::unchecked(ALICE), + cw20_addr, + &Cw20ExecuteMsg::Send { + contract: factory_addr.to_string(), + amount, + msg: to_binary(&ReceiveMsg::InstantiatePayrollContract { + instantiate_msg: instantiate_payroll_msg, + label: "Payroll".to_string(), + }) + .unwrap(), + }, + &coins(amount.into(), NATIVE_DENOM), // https://github.com/CosmWasm/cw-plus/issues/862 + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::WrongFundAmount { + sent: amount, + expected: amount - Uint128::one() + } + ); +} diff --git a/contracts/external/cw-token-swap/.cargo/config b/contracts/external/cw-token-swap/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/external/cw-token-swap/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/external/cw-token-swap/Cargo.toml b/contracts/external/cw-token-swap/Cargo.toml new file mode 100644 index 000000000..e974d5453 --- /dev/null +++ b/contracts/external/cw-token-swap/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cw-token-swap" +authors = ["ekez "] +description = "A CosmWasm contract for swapping native and cw20 assets." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# For more explicit tests, `cargo test --features=backtraces`. +backtraces = ["cosmwasm-std/backtraces"] +# Use library feature to disable all instantiate/execute/query exports. +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } +cw-multi-test = { workspace = true } +cw20-base = { workspace = true } diff --git a/contracts/external/cw-token-swap/README.md b/contracts/external/cw-token-swap/README.md new file mode 100644 index 000000000..82695b3ca --- /dev/null +++ b/contracts/external/cw-token-swap/README.md @@ -0,0 +1,11 @@ +# cw-token-swap + +This is an escrow token swap contract for swapping between native and +cw20 tokens. The contract is instantiated with two counterparties and +their promised funds. Promised funds may either be native tokens or +cw20 tokens. Upon both counterparties providing the promised funds the +transaction is completed and both sides receive their tokens. + +At any time before the other counterparty has provided funds a +counterparty may withdraw their funds. + diff --git a/contracts/external/cw-token-swap/examples/schema.rs b/contracts/external/cw-token-swap/examples/schema.rs new file mode 100644 index 000000000..e3f8ee6ca --- /dev/null +++ b/contracts/external/cw-token-swap/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use cw_token_swap::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/external/cw-token-swap/schema/cw-token-swap.json b/contracts/external/cw-token-swap/schema/cw-token-swap.json new file mode 100644 index 000000000..dd0400086 --- /dev/null +++ b/contracts/external/cw-token-swap/schema/cw-token-swap.json @@ -0,0 +1,317 @@ +{ + "contract_name": "cw-token-swap", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "counterparty_one", + "counterparty_two" + ], + "properties": { + "counterparty_one": { + "$ref": "#/definitions/Counterparty" + }, + "counterparty_two": { + "$ref": "#/definitions/Counterparty" + } + }, + "additionalProperties": false, + "definitions": { + "Counterparty": { + "description": "Information about a counterparty in this escrow transaction and their promised funds.", + "type": "object", + "required": [ + "address", + "promise" + ], + "properties": { + "address": { + "description": "The address of the counterparty.", + "type": "string" + }, + "promise": { + "description": "The funds they have promised to provide.", + "allOf": [ + { + "$ref": "#/definitions/TokenInfo" + } + ] + } + }, + "additionalProperties": false + }, + "TokenInfo": { + "description": "Information about the token being used on one side of the escrow.", + "oneOf": [ + { + "description": "A native token.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 token.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "object", + "required": [ + "amount", + "contract_addr" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "contract_addr": { + "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": "Used to provide cw20 tokens to satisfy a funds promise.", + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Provides native tokens to satisfy a funds promise.", + "type": "object", + "required": [ + "fund" + ], + "properties": { + "fund": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Withdraws provided funds. Only allowed if the other counterparty has yet to provide their promised funds.", + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "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 + }, + "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": [ + "status" + ], + "properties": { + "status": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "status": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StatusResponse", + "type": "object", + "required": [ + "counterparty_one", + "counterparty_two" + ], + "properties": { + "counterparty_one": { + "$ref": "#/definitions/CheckedCounterparty" + }, + "counterparty_two": { + "$ref": "#/definitions/CheckedCounterparty" + } + }, + "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" + }, + "CheckedCounterparty": { + "type": "object", + "required": [ + "address", + "promise", + "provided" + ], + "properties": { + "address": { + "$ref": "#/definitions/Addr" + }, + "promise": { + "$ref": "#/definitions/CheckedTokenInfo" + }, + "provided": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "CheckedTokenInfo": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "object", + "required": [ + "amount", + "contract_addr" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "contract_addr": { + "$ref": "#/definitions/Addr" + } + }, + "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/external/cw-token-swap/src/contract.rs b/contracts/external/cw-token-swap/src/contract.rs new file mode 100644 index 000000000..283ea5ac0 --- /dev/null +++ b/contracts/external/cw-token-swap/src/contract.rs @@ -0,0 +1,254 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult, Uint128, +}; +use cw2::set_contract_version; +use cw_storage_plus::Item; +use cw_utils::must_pay; + +use crate::{ + error::ContractError, + msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, StatusResponse}, + state::{CheckedCounterparty, CheckedTokenInfo, COUNTERPARTY_ONE, COUNTERPARTY_TWO}, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:cw-token-swap"; +pub(crate) 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)?; + + let counterparty_one = msg.counterparty_one.into_checked(deps.as_ref())?; + let counterparty_two = msg.counterparty_two.into_checked(deps.as_ref())?; + + if counterparty_one.address == counterparty_two.address { + return Err(ContractError::NonDistinctCounterparties {}); + } + + COUNTERPARTY_ONE.save(deps.storage, &counterparty_one)?; + COUNTERPARTY_TWO.save(deps.storage, &counterparty_two)?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("counterparty_one", counterparty_one.address) + .add_attribute("counterparty_two", counterparty_two.address)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Receive(msg) => execute_receive(deps, info.sender, msg), + ExecuteMsg::Fund {} => execute_fund(deps, info), + ExecuteMsg::Withdraw {} => execute_withdraw(deps, info), + } +} + +struct CounterpartyResponse<'a> { + pub counterparty: CheckedCounterparty, + pub other_counterparty: CheckedCounterparty, + pub storage: Item<'a, CheckedCounterparty>, +} + +fn get_counterparty<'a>( + deps: Deps, + sender: &Addr, +) -> Result, ContractError> { + let counterparty_one = COUNTERPARTY_ONE.load(deps.storage)?; + let counterparty_two = COUNTERPARTY_TWO.load(deps.storage)?; + + let (counterparty, other_counterparty, storage) = if *sender == counterparty_one.address { + (counterparty_one, counterparty_two, COUNTERPARTY_ONE) + } else if *sender == counterparty_two.address { + (counterparty_two, counterparty_one, COUNTERPARTY_TWO) + } else { + // Contract may only be funded by a counterparty. + return Err(ContractError::Unauthorized {}); + }; + + Ok(CounterpartyResponse { + counterparty, + other_counterparty, + storage, + }) +} + +/// Accepts funding from COUNTERPARTY for the escrow. Distributes +/// escrow funds if both counterparties have funded the contract. +/// +/// NOTE: The caller must verify that the denom of PAID is correct. +fn do_fund( + deps: DepsMut, + counterparty: CheckedCounterparty, + paid: Uint128, + expected: Uint128, + other_counterparty: CheckedCounterparty, + storage: Item, +) -> Result { + if counterparty.provided { + return Err(ContractError::AlreadyProvided {}); + } + + if paid != expected { + return Err(ContractError::InvalidAmount { + expected, + actual: paid, + }); + } + + let mut counterparty = counterparty; + counterparty.provided = true; + storage.save(deps.storage, &counterparty)?; + + let messages = if counterparty.provided && other_counterparty.provided { + vec![ + counterparty + .promise + .into_send_message(&other_counterparty.address)?, + other_counterparty + .promise + .into_send_message(&counterparty.address)?, + ] + } else { + vec![] + }; + + Ok(Response::new() + .add_attribute("method", "fund_escrow") + .add_attribute("counterparty", counterparty.address) + .add_messages(messages)) +} + +pub fn execute_receive( + deps: DepsMut, + token_contract: Addr, + msg: cw20::Cw20ReceiveMsg, +) -> Result { + let sender = deps.api.addr_validate(&msg.sender)?; + + let CounterpartyResponse { + counterparty, + other_counterparty, + storage, + } = get_counterparty(deps.as_ref(), &sender)?; + + let (expected_payment, paid) = if let CheckedTokenInfo::Cw20 { + contract_addr, + amount, + } = &counterparty.promise + { + if *contract_addr != token_contract { + // Must fund with the promised tokens. + return Err(ContractError::InvalidFunds {}); + } + + (*amount, msg.amount) + } else { + return Err(ContractError::InvalidFunds {}); + }; + + do_fund( + deps, + counterparty, + paid, + expected_payment, + other_counterparty, + storage, + ) +} + +pub fn execute_fund(deps: DepsMut, info: MessageInfo) -> Result { + let CounterpartyResponse { + counterparty, + other_counterparty, + storage, + } = get_counterparty(deps.as_ref(), &info.sender)?; + + let (expected_payment, paid) = + if let CheckedTokenInfo::Native { amount, denom } = &counterparty.promise { + let paid = must_pay(&info, denom).map_err(|_| ContractError::InvalidFunds {})?; + + (*amount, paid) + } else { + return Err(ContractError::InvalidFunds {}); + }; + + do_fund( + deps, + counterparty, + paid, + expected_payment, + other_counterparty, + storage, + ) +} + +pub fn execute_withdraw(deps: DepsMut, info: MessageInfo) -> Result { + let CounterpartyResponse { + counterparty, + other_counterparty, + storage, + } = get_counterparty(deps.as_ref(), &info.sender)?; + + if !counterparty.provided { + return Err(ContractError::NoProvision {}); + } + + // The escrow contract completes itself in the same transaction + // that the second counterparty sends its funds. If that has + // happens no more withdrawals are allowed. This check isn't + // strictly needed because the contract won't have enough balance + // anyhow, but we may as well error nicely. + if counterparty.provided && other_counterparty.provided { + return Err(ContractError::Complete {}); + } + + let message = counterparty + .promise + .clone() + .into_send_message(&counterparty.address)?; + + let mut counterparty = counterparty; + counterparty.provided = false; + storage.save(deps.storage, &counterparty)?; + + Ok(Response::new() + .add_attribute("method", "withdraw") + .add_attribute("counterparty", counterparty.address) + .add_message(message)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Status {} => query_status(deps), + } +} + +pub fn query_status(deps: Deps) -> StdResult { + let counterparty_one = COUNTERPARTY_ONE.load(deps.storage)?; + let counterparty_two = COUNTERPARTY_TWO.load(deps.storage)?; + + to_binary(&StatusResponse { + counterparty_one, + counterparty_two, + }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/external/cw-token-swap/src/error.rs b/contracts/external/cw-token-swap/src/error.rs new file mode 100644 index 000000000..df3961398 --- /dev/null +++ b/contracts/external/cw-token-swap/src/error.rs @@ -0,0 +1,33 @@ +use cosmwasm_std::{StdError, Uint128}; +use thiserror::Error; + +#[derive(Error, Debug)] +#[cfg_attr(test, derive(PartialEq))] // Only neeed while testing. +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Counterparties must have different addresses")] + NonDistinctCounterparties {}, + + #[error("Can not provide funds more than once")] + AlreadyProvided {}, + + #[error("Escrow funds have already been sent")] + Complete {}, + + #[error("Must provide funds before withdrawing")] + NoProvision {}, + + #[error("Can not create an escrow for zero tokens")] + ZeroTokens {}, + + #[error("Provided funds do not match promised funds")] + InvalidFunds {}, + + #[error("Invalid amount. Expected ({expected}), got ({actual})")] + InvalidAmount { expected: Uint128, actual: Uint128 }, +} diff --git a/contracts/external/cw-token-swap/src/lib.rs b/contracts/external/cw-token-swap/src/lib.rs new file mode 100644 index 000000000..d1800adbc --- /dev/null +++ b/contracts/external/cw-token-swap/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/external/cw-token-swap/src/msg.rs b/contracts/external/cw-token-swap/src/msg.rs new file mode 100644 index 000000000..1c591d111 --- /dev/null +++ b/contracts/external/cw-token-swap/src/msg.rs @@ -0,0 +1,60 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +use crate::state::CheckedCounterparty; + +/// Information about the token being used on one side of the escrow. +#[cw_serde] +pub enum TokenInfo { + /// A native token. + Native { denom: String, amount: Uint128 }, + /// A cw20 token. + Cw20 { + contract_addr: String, + amount: Uint128, + }, +} + +/// Information about a counterparty in this escrow transaction and +/// their promised funds. +#[cw_serde] +pub struct Counterparty { + /// The address of the counterparty. + pub address: String, + /// The funds they have promised to provide. + pub promise: TokenInfo, +} + +#[cw_serde] +pub struct InstantiateMsg { + pub counterparty_one: Counterparty, + pub counterparty_two: Counterparty, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Used to provide cw20 tokens to satisfy a funds promise. + Receive(cw20::Cw20ReceiveMsg), + /// Provides native tokens to satisfy a funds promise. + Fund {}, + /// Withdraws provided funds. Only allowed if the other + /// counterparty has yet to provide their promised funds. + Withdraw {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + // Gets the current status of the escrow transaction. + #[returns(crate::msg::StatusResponse)] + Status {}, +} + +#[cw_serde] +pub struct StatusResponse { + pub counterparty_one: CheckedCounterparty, + pub counterparty_two: CheckedCounterparty, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/external/cw-token-swap/src/state.rs b/contracts/external/cw-token-swap/src/state.rs new file mode 100644 index 000000000..7ea5cd1a5 --- /dev/null +++ b/contracts/external/cw-token-swap/src/state.rs @@ -0,0 +1,144 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, Addr, BankMsg, Coin, CosmosMsg, Deps, StdError, Uint128, WasmMsg}; +use cw_storage_plus::Item; + +use crate::{ + msg::{Counterparty, TokenInfo}, + ContractError, +}; + +#[cw_serde] +pub enum CheckedTokenInfo { + Native { + denom: String, + amount: Uint128, + }, + Cw20 { + contract_addr: Addr, + amount: Uint128, + }, +} + +#[cw_serde] +pub struct CheckedCounterparty { + pub address: Addr, + pub promise: CheckedTokenInfo, + pub provided: bool, +} + +pub const COUNTERPARTY_ONE: Item = Item::new("counterparty_one"); +pub const COUNTERPARTY_TWO: Item = Item::new("counterparty_two"); + +impl Counterparty { + pub fn into_checked(self, deps: Deps) -> Result { + Ok(CheckedCounterparty { + address: deps.api.addr_validate(&self.address)?, + provided: false, + promise: self.promise.into_checked(deps)?, + }) + } +} + +impl TokenInfo { + pub fn into_checked(self, deps: Deps) -> Result { + match self { + TokenInfo::Native { denom, amount } => { + if amount.is_zero() { + Err(ContractError::ZeroTokens {}) + } else { + Ok(CheckedTokenInfo::Native { denom, amount }) + } + } + TokenInfo::Cw20 { + contract_addr, + amount, + } => { + if amount.is_zero() { + Err(ContractError::ZeroTokens {}) + } else { + let contract_addr = deps.api.addr_validate(&contract_addr)?; + // Make sure we are dealing with a cw20. + let _: cw20::TokenInfoResponse = deps.querier.query_wasm_smart( + contract_addr.clone(), + &cw20::Cw20QueryMsg::TokenInfo {}, + )?; + Ok(CheckedTokenInfo::Cw20 { + contract_addr, + amount, + }) + } + } + } + } +} + +impl CheckedTokenInfo { + pub fn into_send_message(self, recipient: &Addr) -> Result { + Ok(match self { + Self::Native { denom, amount } => BankMsg::Send { + to_address: recipient.to_string(), + amount: vec![Coin { denom, amount }], + } + .into(), + Self::Cw20 { + contract_addr, + amount, + } => WasmMsg::Execute { + contract_addr: contract_addr.into_string(), + msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: recipient.to_string(), + amount, + })?, + funds: vec![], + } + .into(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_into_spend_message_native() { + let info = CheckedTokenInfo::Native { + amount: Uint128::new(100), + denom: "uekez".to_string(), + }; + let message = info.into_send_message(&Addr::unchecked("ekez")).unwrap(); + + assert_eq!( + message, + CosmosMsg::Bank(BankMsg::Send { + to_address: "ekez".to_string(), + amount: vec![Coin { + amount: Uint128::new(100), + denom: "uekez".to_string() + }] + }) + ); + } + + #[test] + fn test_into_spend_message_cw20() { + let info = CheckedTokenInfo::Cw20 { + amount: Uint128::new(100), + contract_addr: Addr::unchecked("ekez_token"), + }; + let message = info.into_send_message(&Addr::unchecked("ekez")).unwrap(); + + assert_eq!( + message, + CosmosMsg::Wasm(WasmMsg::Execute { + funds: vec![], + contract_addr: "ekez_token".to_string(), + msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: "ekez".to_string(), + amount: Uint128::new(100) + }) + .unwrap() + }) + ); + } +} diff --git a/contracts/external/cw-token-swap/src/tests.rs b/contracts/external/cw-token-swap/src/tests.rs new file mode 100644 index 000000000..7e6073f20 --- /dev/null +++ b/contracts/external/cw-token-swap/src/tests.rs @@ -0,0 +1,1063 @@ +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env}, + to_binary, Addr, Coin, Empty, Uint128, +}; +use cw20::Cw20Coin; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; + +use crate::{ + contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}, + msg::{ + Counterparty, ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, StatusResponse, TokenInfo, + }, + state::{CheckedCounterparty, CheckedTokenInfo}, + ContractError, +}; + +const DAO1: &str = "dao1"; +const DAO2: &str = "dao2"; + +fn escrow_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +#[test] +fn test_simple_escrow() { + let mut app = App::default(); + + let cw20_code = app.store_code(cw20_contract()); + let escrow_code = app.store_code(escrow_contract()); + + let cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: DAO2.to_string(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + let escrow = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked(DAO2), + cw20.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.to_string(), + amount: Uint128::new(100), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap(); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: DAO1.to_string(), + amount: vec![Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + })) + .unwrap(); + + app.execute_contract( + Addr::unchecked(DAO1), + escrow, + &ExecuteMsg::Fund {}, + &[Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + ) + .unwrap(); + + let dao1_balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20, + &cw20::Cw20QueryMsg::Balance { + address: DAO1.to_string(), + }, + ) + .unwrap(); + assert_eq!(dao1_balance.balance, Uint128::new(100)); + + let dao2_balance = app.wrap().query_balance(DAO2, "ujuno").unwrap(); + assert_eq!(dao2_balance.amount, Uint128::new(100)) +} + +#[test] +fn test_withdraw() { + let mut app = App::default(); + + let cw20_code = app.store_code(cw20_contract()); + let escrow_code = app.store_code(escrow_contract()); + + let cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: DAO2.to_string(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + let escrow = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap(); + + // Can't withdraw before you provide. + let err: ContractError = app + .execute_contract( + Addr::unchecked(DAO2), + escrow.clone(), + &ExecuteMsg::Withdraw {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::NoProvision {}); + + app.execute_contract( + Addr::unchecked(DAO2), + cw20.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.to_string(), + amount: Uint128::new(100), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap(); + + // Change our minds. + app.execute_contract( + Addr::unchecked(DAO2), + escrow.clone(), + &ExecuteMsg::Withdraw {}, + &[], + ) + .unwrap(); + + let dao2_balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20.clone(), + &cw20::Cw20QueryMsg::Balance { + address: DAO2.to_string(), + }, + ) + .unwrap(); + assert_eq!(dao2_balance.balance, Uint128::new(100)); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: DAO1.to_string(), + amount: vec![Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + })) + .unwrap(); + + app.execute_contract( + Addr::unchecked(DAO1), + escrow.clone(), + &ExecuteMsg::Fund {}, + &[Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + ) + .unwrap(); + + let status: StatusResponse = app + .wrap() + .query_wasm_smart(escrow.clone(), &QueryMsg::Status {}) + .unwrap(); + assert_eq!( + status, + StatusResponse { + counterparty_one: CheckedCounterparty { + address: Addr::unchecked(DAO1), + promise: CheckedTokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100) + }, + provided: true, + }, + counterparty_two: CheckedCounterparty { + address: Addr::unchecked(DAO2), + promise: CheckedTokenInfo::Cw20 { + contract_addr: cw20.clone(), + amount: Uint128::new(100) + }, + provided: false, + } + } + ); + + // Change our minds. + app.execute_contract( + Addr::unchecked(DAO1), + escrow.clone(), + &ExecuteMsg::Withdraw {}, + &[], + ) + .unwrap(); + + let dao1_balance = app.wrap().query_balance(DAO1, "ujuno").unwrap(); + assert_eq!(dao1_balance.amount, Uint128::new(100)); + + let status: StatusResponse = app + .wrap() + .query_wasm_smart(escrow, &QueryMsg::Status {}) + .unwrap(); + assert_eq!( + status, + StatusResponse { + counterparty_one: CheckedCounterparty { + address: Addr::unchecked(DAO1), + promise: CheckedTokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100) + }, + provided: false, + }, + counterparty_two: CheckedCounterparty { + address: Addr::unchecked(DAO2), + promise: CheckedTokenInfo::Cw20 { + contract_addr: cw20, + amount: Uint128::new(100) + }, + provided: false, + } + } + ) +} + +#[test] +fn test_withdraw_post_completion() { + let mut app = App::default(); + + let cw20_code = app.store_code(cw20_contract()); + let escrow_code = app.store_code(escrow_contract()); + + let cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: DAO2.to_string(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + let escrow = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked(DAO2), + cw20.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.to_string(), + amount: Uint128::new(100), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap(); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: DAO1.to_string(), + amount: vec![Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + })) + .unwrap(); + + app.execute_contract( + Addr::unchecked(DAO1), + escrow.clone(), + &ExecuteMsg::Fund {}, + &[Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + ) + .unwrap(); + + let dao1_balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20, + &cw20::Cw20QueryMsg::Balance { + address: DAO1.to_string(), + }, + ) + .unwrap(); + assert_eq!(dao1_balance.balance, Uint128::new(100)); + + let dao2_balance = app.wrap().query_balance(DAO2, "ujuno").unwrap(); + assert_eq!(dao2_balance.amount, Uint128::new(100)); + + let err: ContractError = app + .execute_contract(Addr::unchecked(DAO1), escrow, &ExecuteMsg::Withdraw {}, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Complete {}) +} + +#[test] +fn test_invalid_instantiate() { + let mut app = App::default(); + + let cw20_code = app.store_code(cw20_contract()); + let escrow_code = app.store_code(escrow_contract()); + + let cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: DAO2.to_string(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + // Zero amount not allowed for native tokens. + let err: ContractError = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(0), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::ZeroTokens {})); + + // Zero amount not allowed for cw20 tokens. + let err: ContractError = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(0), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::ZeroTokens {})) +} + +#[test] +fn test_non_distincy_counterparties() { + let mut app = App::default(); + + let escrow_code = app.store_code(escrow_contract()); + + // Zero amount not allowed for native tokens. + let err: ContractError = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(110), + }, + }, + counterparty_two: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(10), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::NonDistinctCounterparties {})); +} + +#[test] +fn test_fund_non_counterparty() { + let mut app = App::default(); + + let cw20_code = app.store_code(cw20_contract()); + let escrow_code = app.store_code(escrow_contract()); + + let cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: "noah".to_string(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + let escrow = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked("noah"), + cw20, + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.to_string(), + amount: Uint128::new(100), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::Unauthorized {})); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: "noah".to_string(), + amount: vec![Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + })) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked("noah"), + escrow, + &ExecuteMsg::Fund {}, + &[Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::Unauthorized {})); +} + +#[test] +fn test_fund_twice() { + let mut app = App::default(); + + let cw20_code = app.store_code(cw20_contract()); + let escrow_code = app.store_code(escrow_contract()); + + let cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: DAO2.to_string(), + amount: Uint128::new(200), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + let escrow = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked(DAO2), + cw20.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.to_string(), + amount: Uint128::new(100), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap(); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: DAO1.to_string(), + amount: vec![Coin { + amount: Uint128::new(200), + denom: "ujuno".to_string(), + }], + })) + .unwrap(); + + app.execute_contract( + Addr::unchecked(DAO1), + escrow.clone(), + &ExecuteMsg::Fund {}, + &[Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + ) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(DAO1), + escrow.clone(), + &ExecuteMsg::Fund {}, + &[Coin { + amount: Uint128::new(100), + denom: "ujuno".to_string(), + }], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::AlreadyProvided {})); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(DAO2), + cw20, + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.into_string(), + amount: Uint128::new(100), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::AlreadyProvided {})); +} + +#[test] +fn test_fund_invalid_amount() { + let mut app = App::default(); + + let cw20_code = app.store_code(cw20_contract()); + let escrow_code = app.store_code(escrow_contract()); + + let cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: DAO2.to_string(), + amount: Uint128::new(200), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + let escrow = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(DAO2), + cw20, + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.to_string(), + amount: Uint128::new(10), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + let expected = ContractError::InvalidAmount { + expected: Uint128::new(100), + actual: Uint128::new(10), + }; + assert_eq!(err, expected); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: DAO1.to_string(), + amount: vec![Coin { + amount: Uint128::new(200), + denom: "ujuno".to_string(), + }], + })) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(DAO1), + escrow, + &ExecuteMsg::Fund {}, + &[Coin { + amount: Uint128::new(200), + denom: "ujuno".to_string(), + }], + ) + .unwrap_err() + .downcast() + .unwrap(); + + let expected = ContractError::InvalidAmount { + expected: Uint128::new(100), + actual: Uint128::new(200), + }; + assert_eq!(err, expected); +} + +#[test] +fn test_fund_invalid_denom() { + let mut app = App::default(); + + let escrow_code = app.store_code(escrow_contract()); + + let escrow = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Native { + denom: "uekez".to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap(); + + // Coutnerparty one tries to fund in the denom of counterparty + // two. + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: DAO1.to_string(), + amount: vec![Coin { + amount: Uint128::new(100), + denom: "uekez".to_string(), + }], + })) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(DAO1), + escrow, + &ExecuteMsg::Fund {}, + &[Coin { + amount: Uint128::new(100), + denom: "uekez".to_string(), + }], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::InvalidFunds {}) +} + +#[test] +fn test_fund_invalid_cw20() { + let mut app = App::default(); + + let escrow_code = app.store_code(escrow_contract()); + let cw20_code = app.store_code(cw20_contract()); + + let cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: DAO1.to_string(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + let bad_cw20 = app + .instantiate_contract( + cw20_code, + Addr::unchecked(DAO2), + &cw20_base::msg::InstantiateMsg { + name: "coin coin".to_string(), + symbol: "coin".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: DAO2.to_string(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "coin", + None, + ) + .unwrap(); + + let escrow = app + .instantiate_contract( + escrow_code, + Addr::unchecked(DAO1), + &InstantiateMsg { + counterparty_one: Counterparty { + address: DAO1.to_string(), + promise: TokenInfo::Native { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }, + }, + counterparty_two: Counterparty { + address: DAO2.to_string(), + promise: TokenInfo::Cw20 { + contract_addr: cw20.to_string(), + amount: Uint128::new(100), + }, + }, + }, + &[], + "escrow", + None, + ) + .unwrap(); + + // Try and fund the contract with the wrong cw20. + let err: ContractError = app + .execute_contract( + Addr::unchecked(DAO2), + bad_cw20, + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.to_string(), + amount: Uint128::new(100), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::InvalidFunds {}); + + // Try and fund the contract with the correct cw20 but incorrect + // provider. + let err: ContractError = app + .execute_contract( + Addr::unchecked(DAO1), + cw20, + &cw20::Cw20ExecuteMsg::Send { + contract: escrow.to_string(), + amount: Uint128::new(100), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::InvalidFunds {}) +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").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); +} diff --git a/contracts/external/cw-vesting/.cargo/config b/contracts/external/cw-vesting/.cargo/config new file mode 100644 index 000000000..8d4bc738b --- /dev/null +++ b/contracts/external/cw-vesting/.cargo/config @@ -0,0 +1,6 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +integration-test = "test --test integration" +schema = "run --example schema" diff --git a/contracts/external/cw-vesting/Cargo.toml b/contracts/external/cw-vesting/Cargo.toml new file mode 100644 index 000000000..039d37d49 --- /dev/null +++ b/contracts/external/cw-vesting/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "cw-vesting" +authors = ["Jake Hartnell", "ekez ", "blue-note"] +description = "A CosmWasm vesting contract." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true, features = ["staking"] } +cw-denom = { workspace = true } +cw-ownable = { workspace = true } +cw-paginate-storage = { workspace = true } +cw-stake-tracker = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw-wormhole = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +wynd-utils = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cw-multi-test = { workspace = true } +cw20-base = { workspace = true } +dao-testing = { workspace = true } diff --git a/contracts/external/cw-vesting/README.md b/contracts/external/cw-vesting/README.md new file mode 100644 index 000000000..a8d918c10 --- /dev/null +++ b/contracts/external/cw-vesting/README.md @@ -0,0 +1,168 @@ +# cw-vesting + +This contract enables the creation of native && cw20 token streams, which allows a payment to be vested continuously over time. + +Key features include: + +- Optional contract owner, with ability to cancel payments +- Support for native and cw20 tokens +- Allows for automated distribution via external parties or tools like [CronCat](https://cron.cat/) +- For payments in a chain governance token, the ability to stake and claim staking rewards +- Complex configuration for vesting schedules powered by [wynd-utils](https://github.com/cosmorama/wynddao/tree/main/packages/utils) + +## Instantiation + +To instantiate a new instance of this contract you may specify a contract owner, as well as payment parameters. + +`cw-payroll-factory` can be used if you wish to instantiate many `cw-vesting` contracts and query them. + +### Parameters + +The `owner` of a contract is optional. Contracts without owners are not able to be canceled. The owner can be set to the DAO making the payment or a neutral third party. + +#### Vesting curves + +This package uses the curve implementation from [wynd-utils](https://github.com/cosmorama/wynddao/tree/main/packages/utils). + +It supports 2 types of [curves](https://docs.rs/wynd-utils/0.4.1/wynd_utils/enum.Curve.html) that represent the vesting schedule: + +- Saturating Linear: vests at a linear rate with a start and stop time. +- Piecewise Linear: linearally interpolates between a set of `(time, vested)` points + +##### Piecewise Linear + +Piecsewise Curves can be used to create more complicated vesting +schedules. For example, let's say we have a schedule that vests 50% +over 1 month and the remaining 50% over 1 year. We can implement this +complex schedule with a Piecewise Linear curve. + +Piecewise Linear curves take a `steps` parameter which is a list of +tuples `(timestamp, vested)`. It will then linearally interpolate +between those points to create the vesting curve. For example, given +the points `(0, 0), (2, 2), (4, 8)`, it would create a vesting curve +that looks like this: + +```text + 8 +----------------------------------------------------------------------+ + | + + + + + + + ** | + 7 |-+ ** +-| + | *** | + | ** | + 6 |-+ ** +-| + | *** | + 5 |-+ ** +-| + | ** | + | *** | + 4 |-+ ** +-| + | ** | + 3 |-+ *** +-| + | ** | + | ** | + 2 |-+ ***** +-| + | ******* | + 1 |-+ ******** +-| + | ******* | + | ******* + + + + + + | + 0 +----------------------------------------------------------------------+ + 0 0.5 1 1.5 2 2.5 3 3.5 4 +``` + +As you can see, it travels through `(0, 0)` in a straight line to `(2, +2)`, then increases its slope and travels to `(4, 8)`. + +A curve where 50% vests the first month starting January 1st 2023, and +the remaining 50% vests over the next year. For 100 Juno. + +```json +{ + "piecewise_linear": [ + (1672531200, "0"), + (1675209600, "50000000"), + (1706745600, "100000000") + ] +} +``` + +### Creating native token vesting + +If vesting native tokens, you need to include the exact amount in native funds that you are vesting when you instantiate the contract. + +### Creating a CW20 Vesting + +A cw20 vesting payment can be funded using the cw20 [Send / Receive](https://github.com/CosmWasm/cw-plus/blob/main/packages/cw20/README.md#receiver) flow. This involves triggering a Send message from the cw20 token contract, with a Receive callback that's sent to the vesting contract. + +## Distribute payments + +Vesting payments can be claimed continuously at any point after the start time by triggering a Distribute message. + +_Anyone_ can call the distribute message, allowing for agents such as [CronCat](https://cron.cat/) to automatically trigger payouts. + +## Staking native tokens + +This contract allows for underlying native tokens to be staked if they +match the staking token of the native chain (i.e. $JUNO on [Juno +Network](https://junonetwork.io)). + +`Delegate`, `Undelegate`, `Redelegate`, and `SetWithdrawAddress` can +_only_ be called by the `recipient`. `WithdrawDelegatorReward` can be +called by anyone to allow for easy auto-compounding. Due to +limitations to our ability to inspect the SDK's state from CosmWasm, +only funds that may be redelegated immediately (w/o an unbonding +period) may be redelegated. + +#### Limitations + +While this contract allows for delegating native tokens, it does not +allow for voting. As such, be sure to pick validators you delegate to +wisely when using this contract. + +## Cancellation + +This vesting contract supports optional cancellation. For example, if +an employee has to leave a company for whatever reason, the company +can vote to have the employee salary canceled. + +This is only possible if an `owner` address is set upon contract +instantiation, otherwise the vesting contract cannot be altered by +either party. + +When a contract is cancelled, the following happens: + +1. All liquid tokens (non-staked) in the vesting contract are used to + settle any undistributed, vested funds owed to the receiver. +2. Any leftover liquid tokens are returned to the contract owner. +3. Calls to `Delegate` are `Redelegate` are disabled. +4. Calls to `Undelegate` are made permissionless (allowing anyone to + undelegate the contract's staked tokens). +5. Any pending staking rewards are claimed by the owner, and future + staking rewards are directed to the owner. + +It is imagined that frontends will prompt visitors to execute +undelegations, or a bot will do so. The contract can not automatically +undelegate as that would allow a malicious vest receiver to stake to +many validators and make cancelation run out of gas, preventing the +contract from being cancelable and allowing them to continue to +receive funds. + +## Stable coin support + +This contract can be used with stable coins such as $USDC. It does not +yet support auto swapping to stables, however this feature can be +enabled with other contracts or tools like +[CronCat](https://cron.cat/). + +DAOs always have an option of swapping to stables before creating a +vesting contract ensuring no price slippage. For example, a proposal +to pay someone 50% $USDC could contain three messages: + +1. Swap 50% of grant tokens for $USDC +2. Instantiate a vesting contract for the $USDC +3. Instantiate a vesting contract for the native DAO token + +## Attribution + +Thank you to Wynd DAO for their previous work on +[cw20-vesting](https://github.com/cosmorama/wynddao/tree/main/contracts/cw20-vesting) +and their [curve +package](https://github.com/cosmorama/wynddao/tree/main/packages/utils) +which informed and inspired this contract's design. diff --git a/contracts/external/cw-vesting/SECURITY.md b/contracts/external/cw-vesting/SECURITY.md new file mode 100644 index 000000000..b907b0f60 --- /dev/null +++ b/contracts/external/cw-vesting/SECURITY.md @@ -0,0 +1,119 @@ + +We want the following to be true: + +1. I can create a vesting agreement between two parties. +2. The owner of the agreement may cancel it at any time and reclaim + unvested funds. +3. The receiver of the tokens may stake those tokens on the underlying + cosmos-SDK blockchain and receive rewards. + +Requirement two means that: + +1. Funds should never be paid out faster than scheduled. +2. Already vested funds should never be returned to the owner. + +Thrown into the mix by requirement three, is a major complication: the +SDK does not provide hooks when slashing happens. Later I will show +that this means that there is a situation where the contract does not +have enough information to enforce that on cancelation vested funds +are always returned to the receiver. See +`test_owner_registers_slash_after_withdrawal` in +`src/suite_tests/tests.rs` for a test which demonstrates this. + +How do we know our requirements have been met? + +- `src/vesting_tests.rs` tests that the rules of vesting are followed + (1 and 2). +- `src/stake_tracker_tests.rs` tests that staked balances are tracked + properly (3). +- `src/suite_tests/tests.rs` tests that the whole system works well + together in some complex scenerios. +- `src/tests.rs` has some additional integration tests from an earlier + iteration of this contract. +- `ci/integration-tests/src/tests/cw_vesting_test.rs` tests a bond, + withdraw rewards, unbond flow with this contract to ensure that it + behaves correctly against a real cosmos-SDK blockchain. This test is + important because cw-multi-test has some bugs in its x/staking + implementaiton. Tests demonstrating these can be found in + `test_slash_during_unbonding` and `test_redelegation` in the suite + tests. + +## Slashing + +Slashing can happen while tokens are staked to a validator, or while +tokens are unbonding from a validator (ref: unbonding durations +protect against long range attacks). Let's investigate how slashing +impacts this contract. + +In this contract we use two math formulas, $liquid(t)$ tells us the +contract's current liquid token balance, and $claimable(t)$ tells us +how many tokens may be claimed by the vest receiver. As the vest +receiver selects which validators to delegate to, we expect that they +will be penalized for slashing, and not the vest owner. This makes our +formulas: + +$$ liquid(t) := total - claimed(t) - staked(t) - slashed(t) $$ + +$$ claimable(t) := vested(t) - claimed(t) - slashed(t) $$ + +The Cosmos SDK does not provide a way for contracts to be notified +when a slash happens, so let's consider what happens if a slash occurs +and the contract does not know about it. + +### Slashed while staked + +If tokens are slashed while they are staked and the contract does not +know of it, it will make $staked(t) = staked(t) + slashed(t)$, as the +contract will not know of the slash and thus will not deduct it from +the staked balance. This means that $liquid$ will continue to return +correct values. + +$claimable$ on the other hand will report values $slashed(t)$ too +large. this has different impacts depending on if the contract is +canceled or not. + +#### Slash while staked, contract open + +The amount of funds to distribute is $min(liquid(t), +claimable(t))$. This means that in the time following the slash +claimable will be $slashed(t)$ too large and the receiver will be able +to withdraw "too much"; however, once the vest completes $liquid(t)$ +being correct will stop any claiming of funds $\gt total$. + +#### Slashed while staked, contract cancelled + +When a contract is canceled currently liquid funds are sent to the +vest receiver up to the amount that they may claim and, $total$ is set +to $vested(t)$. + +$$ settle = min(claimable(t), liquid(t)) $$ + +Because $claimable(t)$ is $slashed(t)$ too large, **a closed contract +with a slash may distribute too many tokens to the vest +receiver**. This makes the owner bear the cost of shashing. + +### Slashed while unbonding + +If tokens are slashed while they are unbonding this will make +$liquid(t)$ $slashed(t)$ too large, and $claimable(t)$ $slashed(t)$ +too large. + +#### Slashed while unbonding, contract open + +This has the same outcome as being slashed while bonded and open as +the factory pattern will prevent a vesting contract from being able to +distribute more tokens than it has (x/bank to the rescue). + +#### Slashed while unbonding, contract closed + +The contract will not be closable as the overestimate of $liquid(t)$ +will cause the contract to attempt to settle more funds than it has +(x/bank will error). + +### Slashing conclusion + +If slashes are not known about, it can cause bad-ish outcomes. After +much discussion, we decided that these are acceptable. This contract +also provides a message type `RegisterSlash` which allows the owner to +register a slash that has occured and in doing so rebalance the +contract to undo the issues discussed above. diff --git a/contracts/external/cw-vesting/examples/schema.rs b/contracts/external/cw-vesting/examples/schema.rs new file mode 100644 index 000000000..31a0509dd --- /dev/null +++ b/contracts/external/cw-vesting/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use cw_vesting::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/external/cw-vesting/schema/cw-vesting.json b/contracts/external/cw-vesting/schema/cw-vesting.json new file mode 100644 index 000000000..aa6b2aa8b --- /dev/null +++ b/contracts/external/cw-vesting/schema/cw-vesting.json @@ -0,0 +1,1227 @@ +{ + "contract_name": "cw-vesting", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "denom", + "recipient", + "schedule", + "title", + "total", + "unbonding_duration_seconds", + "vesting_duration_seconds" + ], + "properties": { + "denom": { + "description": "The type and denom of token being vested.", + "allOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + } + ] + }, + "description": { + "description": "A description for the payment to provide more context.", + "type": [ + "string", + "null" + ] + }, + "owner": { + "description": "The optional owner address of the contract. If an owner is specified, the owner may cancel the vesting contract at any time and withdraw unvested funds.", + "type": [ + "string", + "null" + ] + }, + "recipient": { + "description": "The receiver address of the vesting tokens.", + "type": "string" + }, + "schedule": { + "description": "The vesting schedule, can be either `SaturatingLinear` vesting (which vests evenly over time), or `PiecewiseLinear` which can represent a more complicated vesting schedule.", + "allOf": [ + { + "$ref": "#/definitions/Schedule" + } + ] + }, + "start_time": { + "description": "The time to start vesting, or None to start vesting when the contract is instantiated. `start_time` may be in the past, though the contract checks that `start_time + vesting_duration_seconds > now`. Otherwise, this would amount to a regular fund transfer.", + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + }, + "title": { + "description": "The a name or title for this payment.", + "type": "string" + }, + "total": { + "description": "The total amount of tokens to be vested.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "unbonding_duration_seconds": { + "description": "The unbonding duration for the chain this contract is deployed on. Smart contracts do not have access to this data as stargate queries are disabled on most chains, and cosmwasm-std provides no way to query it.\n\nThis value being too high will cause this contract to hold funds for longer than needed, this value being too low will reduce the quality of error messages and require additional external calculations with correct values to withdraw avaliable funds from the contract.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vesting_duration_seconds": { + "description": "The length of the vesting schedule in seconds. Must be non-zero, though one second vesting durations are allowed. This may be combined with a `start_time` in the future to create an agreement that instantly vests at a time in the future, and allows the receiver to stake vesting tokens before the agreement completes.\n\nSee `suite_tests/tests.rs` `test_almost_instavest_in_the_future` for an example of this.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Schedule": { + "oneOf": [ + { + "description": "Vests linearally from `0` to `total`.", + "type": "string", + "enum": [ + "saturating_linear" + ] + }, + { + "description": "Vests by linearally interpolating between the provided (seconds, amount) points. The first amount must be zero and the last amount the total vesting amount. `seconds` are seconds since the vest start time.\n\nThere is a problem in the underlying Curve library that doesn't allow zero start values, so the first value of `seconds` must be > 1. To start at a particular time (if you need that level of percision), subtract one from the true start time, and make the first `seconds` value `1`.\n\n", + "type": "object", + "required": [ + "piecewise_linear" + ], + "properties": { + "piecewise_linear": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + { + "$ref": "#/definitions/Uint128" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + }, + "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": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Fund the contract with a cw20 token. The `msg` field must have the shape `{\"fund\":{}}`, and the amount sent must be the same as the amount to be vested (as set during instantiation). Anyone may call this method so long as the contract has not yet been funded.", + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Distribute vested tokens to the vest receiver. Anyone may call this method.", + "type": "object", + "required": [ + "distribute" + ], + "properties": { + "distribute": { + "type": "object", + "properties": { + "amount": { + "description": "The amount of tokens to distribute. If none are specified all claimable tokens will be distributed.", + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Cancels the vesting payment. The current amount vested becomes the total amount that will ever vest, and all pending and future staking rewards from tokens staked by this contract will be sent to the owner. Tote that canceling does not impact already vested tokens.\n\nUpon canceling, the contract will use any liquid tokens in the contract to settle pending payments to the vestee, and then returns the rest to the owner. Staked tokens are then split between the owner and the vestee according to the number of tokens that the vestee is entitled to.\n\nThe vestee will no longer receive staking rewards after cancelation, and may unbond and distribute (vested - claimed) tokens at their leisure. the owner will receive staking rewards and may unbond and withdraw (staked - (vested - claimed)) tokens at their leisure.", + "type": "object", + "required": [ + "cancel" + ], + "properties": { + "cancel": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address. Note: this only works with the native staking denom of a Cosmos chain. Only callable by Vesting Payment Recipient.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "description": "The amount to delegate.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "validator": { + "description": "The validator to delegate to.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L96). `delegator_address` is automatically filled with the current contract's address. Only callable by Vesting Payment Recipient.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address. Only callable by Vesting Payment Recipient.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "description": "The amount to delegate", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "validator": { + "description": "The validator to undelegate from", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L31-L37). `delegator_address` is automatically filled with the current contract's address. Only callable by Vesting Payment Recipient.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The validator to claim rewards for.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "If the owner cancels a payment and there are not enough liquid tokens to settle the owner may become entitled to some number of staked tokens. They may then unbond those tokens and then call this method to return them.", + "type": "object", + "required": [ + "withdraw_canceled_payment" + ], + "properties": { + "withdraw_canceled_payment": { + "type": "object", + "properties": { + "amount": { + "description": "The amount to withdraw.", + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Registers a slash event bonded or unbonding tokens with the contract. Only callable by the owner as the contract is unable to verify that the slash actually occured. The owner is assumed to be honest.\n\nA future version of this contract may be able to permissionlessly take slashing evidence: ", + "type": "object", + "required": [ + "register_slash" + ], + "properties": { + "register_slash": { + "type": "object", + "required": [ + "amount", + "during_unbonding", + "time", + "validator" + ], + "properties": { + "amount": { + "description": "The number of tokens that THIS CONTRACT lost as a result of the slash. Note that this differs from the total amount slashed from the validator.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "during_unbonding": { + "description": "If the slash happened during unbonding. Set to false in the common case where the slash impacted bonding tokens.", + "type": "boolean" + }, + "time": { + "description": "The time the slash event occured. Note that this is not validated beyond validating that it is < now. This means that if two slash events occur for a single validator, and then this method is called, a dishonest sender could register those two slashes as a single larger one at the time of the first slash.\n\nThe result of this is that the staked balances tracked in this contract can not be relied on for accurate values in the past. Staked balances will be correct at time=now.", + "allOf": [ + { + "$ref": "#/definitions/Timestamp" + } + ] + }, + "validator": { + "description": "The validator the slash occured for.", + "type": "string" + } + }, + "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" + ] + } + ] + }, + "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 + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Get the current ownership.", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns information about the vesting contract and the status of the payment.", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the number of tokens currently claimable by the vestee. This is the minimum of the number of unstaked tokens in the contract, and the number of tokens that have been vested at time t.", + "type": "object", + "required": [ + "distributable" + ], + "properties": { + "distributable": { + "type": "object", + "properties": { + "t": { + "description": "The time or none to use the current time.", + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the current value of `vested(t)`. If `t` is `None`, the current time is used.", + "type": "object", + "required": [ + "vested" + ], + "properties": { + "vested": { + "type": "object", + "properties": { + "t": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the total amount that will ever vest, `max(vested(t))`.\n\nNote that if the contract is canceled at time c, this value will change to `vested(c)`. Thus, it can not be assumed to be constant over the contract's lifetime.", + "type": "object", + "required": [ + "total_to_vest" + ], + "properties": { + "total_to_vest": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the amount of time between the vest starting, and it completing. Returns `None` if the vest has been cancelled.", + "type": "object", + "required": [ + "vest_duration" + ], + "properties": { + "vest_duration": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Queries information about the contract's understanding of it's bonded and unbonding token balances. See the `StakeTrackerQuery` in `packages/cw-stake-tracker/lib.rs` for query methods and their return types.", + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "$ref": "#/definitions/StakeTrackerQuery" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "StakeTrackerQuery": { + "oneOf": [ + { + "type": "object", + "required": [ + "cardinality" + ], + "properties": { + "cardinality": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "total_staked" + ], + "properties": { + "total_staked": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "validator_staked" + ], + "properties": { + "validator_staked": { + "type": "object", + "required": [ + "t", + "validator" + ], + "properties": { + "t": { + "$ref": "#/definitions/Timestamp" + }, + "validator": { + "type": "string" + } + }, + "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" + } + } + }, + "migrate": null, + "sudo": null, + "responses": { + "distributable": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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": "Vest", + "type": "object", + "required": [ + "claimed", + "denom", + "recipient", + "slashed", + "start_time", + "status", + "title", + "vested" + ], + "properties": { + "claimed": { + "description": "The number of tokens that have been claimed by the vest receiver.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "$ref": "#/definitions/CheckedDenom" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "recipient": { + "$ref": "#/definitions/Addr" + }, + "slashed": { + "description": "The number of tokens that have been slashed while staked by the vest receiver. Slashed tokens count against the number of tokens the receiver is entitled to.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "start_time": { + "$ref": "#/definitions/Timestamp" + }, + "status": { + "$ref": "#/definitions/Status" + }, + "title": { + "type": "string" + }, + "vested": { + "description": "vested(t), where t is seconds since start_time.", + "allOf": [ + { + "$ref": "#/definitions/Curve" + } + ] + } + }, + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "Curve": { + "oneOf": [ + { + "type": "object", + "required": [ + "constant" + ], + "properties": { + "constant": { + "type": "object", + "required": [ + "y" + ], + "properties": { + "y": { + "$ref": "#/definitions/Uint128" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "saturating_linear" + ], + "properties": { + "saturating_linear": { + "$ref": "#/definitions/SaturatingLinear" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "piecewise_linear" + ], + "properties": { + "piecewise_linear": { + "$ref": "#/definitions/PiecewiseLinear" + } + }, + "additionalProperties": false + } + ] + }, + "PiecewiseLinear": { + "description": "This is a generalization of SaturatingLinear, steps must be arranged with increasing time (u64). Any point before first step gets the first value, after last step the last value. Otherwise, it is a linear interpolation between the two closest points. Vec of length 1 -> Constant Vec of length 2 -> SaturatingLinear", + "type": "object", + "required": [ + "steps" + ], + "properties": { + "steps": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + { + "$ref": "#/definitions/Uint128" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + } + }, + "SaturatingLinear": { + "description": "min_y for all x <= min_x, max_y for all x >= max_x, linear in between", + "type": "object", + "required": [ + "max_x", + "max_y", + "min_x", + "min_y" + ], + "properties": { + "max_x": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "max_y": { + "$ref": "#/definitions/Uint128" + }, + "min_x": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "min_y": { + "$ref": "#/definitions/Uint128" + } + } + }, + "Status": { + "oneOf": [ + { + "type": "string", + "enum": [ + "unfunded", + "funded" + ] + }, + { + "type": "object", + "required": [ + "canceled" + ], + "properties": { + "canceled": { + "type": "object", + "required": [ + "owner_withdrawable" + ], + "properties": { + "owner_withdrawable": { + "description": "owner_withdrawable(t). This is monotonically decreasing and will be zero once the owner has completed withdrawing their funds.", + "allOf": [ + { + "$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" + } + } + }, + "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" + } + } + }, + "stake": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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" + }, + "total_to_vest": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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" + }, + "vest_duration": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Nullable_Uint64", + "anyOf": [ + { + "$ref": "#/definitions/Uint64" + }, + { + "type": "null" + } + ], + "definitions": { + "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" + } + } + }, + "vested": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Uint128", + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/external/cw-vesting/src/contract.rs b/contracts/external/cw-vesting/src/contract.rs new file mode 100644 index 000000000..70c0cea96 --- /dev/null +++ b/contracts/external/cw-vesting/src/contract.rs @@ -0,0 +1,465 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + from_binary, to_binary, Binary, Coin, CosmosMsg, DelegationResponse, Deps, DepsMut, + DistributionMsg, Env, MessageInfo, Response, StakingMsg, StakingQuery, StdResult, Timestamp, + Uint128, +}; +use cw2::set_contract_version; +use cw20::Cw20ReceiveMsg; +use cw_denom::CheckedDenom; +use cw_ownable::OwnershipError; +use cw_utils::{must_pay, nonpayable}; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, ReceiveMsg}; +use crate::state::{PAYMENT, UNBONDING_DURATION_SECONDS}; +use crate::vesting::{Status, VestInit}; + +const CONTRACT_NAME: &str = "crates.io:cw-vesting"; +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)?; + cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + + let denom = msg.denom.into_checked(deps.as_ref())?; + let recipient = deps.api.addr_validate(&msg.recipient)?; + let start_time = msg.start_time.unwrap_or(env.block.time); + + if start_time.plus_seconds(msg.vesting_duration_seconds) <= env.block.time { + return Err(ContractError::Instavest); + } + + let vest = PAYMENT.initialize( + deps.storage, + VestInit { + total: msg.total, + schedule: msg.schedule, + start_time, + duration_seconds: msg.vesting_duration_seconds, + denom, + recipient, + title: msg.title, + description: msg.description, + }, + )?; + UNBONDING_DURATION_SECONDS.save(deps.storage, &msg.unbonding_duration_seconds)?; + + let resp = match vest.denom { + CheckedDenom::Native(ref denom) => { + let sent = must_pay(&info, denom)?; + if vest.total() != sent { + return Err(ContractError::WrongFundAmount { + sent, + expected: vest.total(), + }); + } + PAYMENT.set_funded(deps.storage)?; + + // If the payment denomination is the same as the native + // denomination, set the staking rewards receiver to the + // payment receiver so that when they stake vested tokens + // they receive the rewards. + if denom.as_str() == deps.querier.query_bonded_denom()? { + Some(CosmosMsg::Distribution( + DistributionMsg::SetWithdrawAddress { + address: vest.recipient.to_string(), + }, + )) + } else { + None + } + } + CheckedDenom::Cw20(_) => { + nonpayable(&info)?; // Funding happens in ExecuteMsg::Receive. + None + } + }; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("owner", msg.owner.unwrap_or_else(|| "None".to_string())) + .add_messages(resp)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Receive(msg) => execute_receive_cw20(env, deps, info, msg), + ExecuteMsg::Distribute { amount } => execute_distribute(env, deps, amount), + ExecuteMsg::Cancel {} => execute_cancel_vesting_payment(env, deps, info), + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + ExecuteMsg::Delegate { validator, amount } => { + execute_delegate(env, deps, info, validator, amount) + } + ExecuteMsg::Redelegate { + src_validator, + dst_validator, + amount, + } => execute_redelegate(env, deps, info, src_validator, dst_validator, amount), + ExecuteMsg::Undelegate { validator, amount } => { + execute_undelegate(env, deps, info, validator, amount) + } + ExecuteMsg::SetWithdrawAddress { address } => { + execute_set_withdraw_address(deps, env, info, address) + } + ExecuteMsg::WithdrawDelegatorReward { validator } => execute_withdraw_rewards(validator), + ExecuteMsg::WithdrawCanceledPayment { amount } => { + execute_withdraw_canceled_payment(deps, env, amount) + } + ExecuteMsg::RegisterSlash { + validator, + time, + amount, + during_unbonding, + } => execute_register_slash(deps, env, info, validator, time, amount, during_unbonding), + } +} + +pub fn execute_receive_cw20( + _env: Env, + deps: DepsMut, + info: MessageInfo, + receive_msg: Cw20ReceiveMsg, +) -> Result { + // Only accepts cw20 tokens + nonpayable(&info)?; + + let msg: ReceiveMsg = from_binary(&receive_msg.msg)?; + + match msg { + ReceiveMsg::Fund {} => { + let vest = PAYMENT.get_vest(deps.storage)?; + + if vest.total() != receive_msg.amount { + return Err(ContractError::WrongFundAmount { + sent: receive_msg.amount, + expected: vest.total(), + }); + } // correct amount + + if !vest.denom.is_cw20(&info.sender) { + return Err(ContractError::WrongCw20); + } // correct denom + + if vest.status != Status::Unfunded { + return Err(ContractError::Funded); + } // correct status + + PAYMENT.set_funded(deps.storage)?; + + Ok(Response::new() + .add_attribute("method", "fund_cw20_vesting_payment") + .add_attribute("receiver", vest.recipient.to_string())) + } + } +} + +pub fn execute_cancel_vesting_payment( + env: Env, + deps: DepsMut, + info: MessageInfo, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let msgs = PAYMENT.cancel(deps.storage, env.block.time, &info.sender)?; + + Ok(Response::new() + .add_attribute("method", "remove_vesting_payment") + .add_attribute("owner", info.sender) + .add_attribute("removed_time", env.block.time.to_string()) + .add_messages(msgs)) +} + +pub fn execute_distribute( + env: Env, + deps: DepsMut, + request: Option, +) -> Result { + let msg = PAYMENT.distribute(deps.storage, env.block.time, request)?; + + Ok(Response::new() + .add_attribute("method", "distribute") + .add_message(msg)) +} + +pub fn execute_withdraw_canceled_payment( + deps: DepsMut, + env: Env, + amount: Option, +) -> Result { + let owner = cw_ownable::get_ownership(deps.storage)? + .owner + .ok_or(OwnershipError::NoOwner)?; + let msg = PAYMENT.withdraw_canceled_payment(deps.storage, env.block.time, amount, &owner)?; + + Ok(Response::new() + .add_attribute("method", "withdraw_canceled_payment") + .add_message(msg)) +} + +pub fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + if let Status::Canceled { owner_withdrawable } = PAYMENT.get_vest(deps.storage)?.status { + if action == cw_ownable::Action::RenounceOwnership && !owner_withdrawable.is_zero() { + // Ownership cannot be removed if there are withdrawable + // funds as this would lock those funds in the contract. + return Err(ContractError::Cancelled); + } + } + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +pub fn execute_delegate( + env: Env, + deps: DepsMut, + info: MessageInfo, + validator: String, + amount: Uint128, +) -> Result { + nonpayable(&info)?; + + let vest = PAYMENT.get_vest(deps.storage)?; + + match vest.status { + Status::Unfunded => return Err(ContractError::NotFunded), + Status::Funded => { + if info.sender != vest.recipient { + return Err(ContractError::NotReceiver); + } + } + Status::Canceled { .. } => return Err(ContractError::Cancelled), + } + + let denom = deps.querier.query_bonded_denom()?; + if !vest.denom.is_native(&denom) { + return Err(ContractError::NotStakeable); + } + + PAYMENT.on_delegate(deps.storage, env.block.time, validator.clone(), amount)?; + + let msg = StakingMsg::Delegate { + validator: validator.clone(), + amount: Coin { denom, amount }, + }; + + Ok(Response::new() + .add_attribute("method", "delegate") + .add_attribute("amount", amount.to_string()) + .add_attribute("validator", validator) + .add_message(msg)) +} + +pub fn execute_redelegate( + env: Env, + deps: DepsMut, + info: MessageInfo, + src_validator: String, + dst_validator: String, + amount: Uint128, +) -> Result { + nonpayable(&info)?; + + let vest = PAYMENT.get_vest(deps.storage)?; + + match vest.status { + Status::Unfunded => return Err(ContractError::NotFunded), + Status::Funded => { + if info.sender != vest.recipient { + return Err(ContractError::NotReceiver); + } + } + Status::Canceled { .. } => return Err(ContractError::Cancelled), + } + + let denom = deps.querier.query_bonded_denom()?; + if !vest.denom.is_native(&denom) { + return Err(ContractError::NotStakeable); + } + + let resp: DelegationResponse = deps.querier.query( + &StakingQuery::Delegation { + delegator: env.contract.address.into_string(), + validator: src_validator.clone(), + } + .into(), + )?; + + let delegation = resp + .delegation + .ok_or(ContractError::NoDelegation(src_validator.clone()))?; + if delegation.can_redelegate.amount < amount { + return Err(ContractError::NonImmediateRedelegate { + max: delegation.can_redelegate.amount, + }); + } + + PAYMENT.on_redelegate( + deps.storage, + env.block.time, + src_validator.clone(), + dst_validator.clone(), + amount, + )?; + + let msg = StakingMsg::Redelegate { + src_validator: src_validator.clone(), + dst_validator: dst_validator.clone(), + amount: Coin { denom, amount }, + }; + + Ok(Response::new() + .add_attribute("method", "redelegate") + .add_attribute("amount", amount.to_string()) + .add_attribute("src_validator", src_validator) + .add_attribute("dst_validator", dst_validator) + .add_message(msg)) +} + +pub fn execute_undelegate( + env: Env, + deps: DepsMut, + info: MessageInfo, + validator: String, + amount: Uint128, +) -> Result { + nonpayable(&info)?; + + let vest = PAYMENT.get_vest(deps.storage)?; + + match vest.status { + Status::Unfunded => return Err(ContractError::NotFunded), + Status::Funded => { + if info.sender != vest.recipient { + return Err(ContractError::NotReceiver); + } + } + // Anyone can undelegate while the contract is in the canceled + // state. This is to prevent us from neededing to undelegate + // all at once when the contract is canceled which could be a + // DOS vector if the veste staked to 50+ validators. + Status::Canceled { .. } => (), + }; + + let ubs = UNBONDING_DURATION_SECONDS.load(deps.storage)?; + PAYMENT.on_undelegate(deps.storage, env.block.time, validator.clone(), amount, ubs)?; + + let denom = deps.querier.query_bonded_denom()?; + + let msg = StakingMsg::Undelegate { + validator: validator.clone(), + amount: Coin { denom, amount }, + }; + + Ok(Response::default() + .add_message(msg) + .add_attribute("method", "undelegate") + .add_attribute("validator", validator) + .add_attribute("amount", amount)) +} + +pub fn execute_set_withdraw_address( + deps: DepsMut, + env: Env, + info: MessageInfo, + address: String, +) -> Result { + let vest = PAYMENT.get_vest(deps.storage)?; + match vest.status { + Status::Unfunded | Status::Funded => { + if info.sender != vest.recipient { + return Err(ContractError::NotReceiver); + } + } + // In the cancelled state the owner is receiving staking + // rewards and may update the withdraw address. + Status::Canceled { .. } => cw_ownable::assert_owner(deps.storage, &info.sender)?, + } + + if address == env.contract.address { + return Err(ContractError::SelfWithdraw); + } + + let msg = DistributionMsg::SetWithdrawAddress { + address: address.clone(), + }; + + Ok(Response::default() + .add_attribute("method", "set_withdraw_address") + .add_attribute("address", address) + .add_message(msg)) +} + +pub fn execute_withdraw_rewards(validator: String) -> Result { + let withdraw_msg = DistributionMsg::WithdrawDelegatorReward { validator }; + Ok(Response::default() + .add_attribute("method", "execute_withdraw_rewards") + .add_message(withdraw_msg)) +} + +pub fn execute_register_slash( + deps: DepsMut, + env: Env, + info: MessageInfo, + validator: String, + time: Timestamp, + amount: Uint128, + during_unbonding: bool, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + if time > env.block.time { + Err(ContractError::FutureSlash) + } else { + PAYMENT.register_slash( + deps.storage, + validator.clone(), + time, + amount, + during_unbonding, + )?; + Ok(Response::default() + .add_attribute("method", "execute_register_slash") + .add_attribute("during_unbonding", during_unbonding.to_string()) + .add_attribute("validator", validator) + .add_attribute("time", time.to_string()) + .add_attribute("amount", amount)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Ownership {} => to_binary(&cw_ownable::get_ownership(deps.storage)?), + QueryMsg::Info {} => to_binary(&PAYMENT.get_vest(deps.storage)?), + QueryMsg::Distributable { t } => to_binary(&PAYMENT.distributable( + deps.storage, + &PAYMENT.get_vest(deps.storage)?, + t.unwrap_or(env.block.time), + )?), + QueryMsg::Stake(q) => PAYMENT.query_stake(deps.storage, q), + QueryMsg::Vested { t } => to_binary( + &PAYMENT + .get_vest(deps.storage)? + .vested(t.unwrap_or(env.block.time)), + ), + QueryMsg::TotalToVest {} => to_binary(&PAYMENT.get_vest(deps.storage)?.total()), + QueryMsg::VestDuration {} => to_binary(&PAYMENT.duration(deps.storage)?), + } +} diff --git a/contracts/external/cw-vesting/src/error.rs b/contracts/external/cw-vesting/src/error.rs new file mode 100644 index 000000000..c29989fbc --- /dev/null +++ b/contracts/external/cw-vesting/src/error.rs @@ -0,0 +1,84 @@ +use cosmwasm_std::{StdError, Uint128}; +use cw_denom::DenomError; +use cw_ownable::OwnershipError; +use cw_utils::PaymentError; +use thiserror::Error; +use wynd_utils::CurveError; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Curve(#[from] CurveError), + + #[error(transparent)] + Denom(#[from] DenomError), + + #[error(transparent)] + Ownable(#[from] OwnershipError), + + #[error("{0}")] + PaymentError(#[from] PaymentError), + + #[error("vesting curve values be in [0, total]`. got [{min}, {max}]")] + VestRange { min: Uint128, max: Uint128 }, + + #[error("vesting contract vests ({expected}) tokens, funded with ({sent})")] + WrongFundAmount { sent: Uint128, expected: Uint128 }, + + #[error("sent wrong cw20")] + WrongCw20, + + #[error("total amount to vest must be non-zero")] + ZeroVest, + + #[error("this vesting contract would complete instantly")] + Instavest, + + #[error("can not vest a constant amount, specifiy two or more points")] + ConstantVest, + + #[error("payment is cancelled")] + Cancelled, + + #[error("payment is not cancelled")] + NotCancelled, + + #[error("vesting contract is not distributing funds")] + NotFunded, + + #[error("it should not be possible for a slash to occur in the unfunded state")] + UnfundedSlash, + + #[error("vesting contract has already been funded")] + Funded, + + #[error("only the vest receiver may perform this action")] + NotReceiver, + + #[error("vesting denom may not be staked")] + NotStakeable, + + #[error("no delegation to validator {0}")] + NoDelegation(String), + + #[error("slash amount can not be zero")] + NoSlash, + + #[error("can't set wihtdraw address to vesting contract")] + SelfWithdraw, + + #[error("can't redelegate funds that are not immediately redelegatable. max: ({max})")] + NonImmediateRedelegate { max: Uint128 }, + + #[error("request must be <= claimable and > 0. !(0 < {request} <= {claimable})")] + InvalidWithdrawal { + request: Uint128, + claimable: Uint128, + }, + + #[error("can't register a slash event occuring in the future")] + FutureSlash, +} diff --git a/contracts/external/cw-vesting/src/lib.rs b/contracts/external/cw-vesting/src/lib.rs new file mode 100644 index 000000000..a940276ee --- /dev/null +++ b/contracts/external/cw-vesting/src/lib.rs @@ -0,0 +1,23 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; +pub mod vesting; + +pub use crate::error::ContractError; + +// so consumers don't need a cw_ownable dependency to use this contract's queries. +pub use cw_denom::{CheckedDenom, UncheckedDenom}; +pub use cw_ownable::Ownership; + +// so consumers don't need a cw_stake_tracker dependency to use this contract's queries. +pub use cw_stake_tracker::StakeTrackerQuery; + +#[cfg(test)] +mod suite_tests; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod vesting_tests; diff --git a/contracts/external/cw-vesting/src/msg.rs b/contracts/external/cw-vesting/src/msg.rs new file mode 100644 index 000000000..db6c7119f --- /dev/null +++ b/contracts/external/cw-vesting/src/msg.rs @@ -0,0 +1,230 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Timestamp, Uint128}; +use cw20::Cw20ReceiveMsg; +use cw_denom::UncheckedDenom; +use cw_ownable::cw_ownable_execute; +use cw_stake_tracker::StakeTrackerQuery; + +use crate::vesting::Schedule; + +#[cw_serde] +pub struct InstantiateMsg { + /// The optional owner address of the contract. If an owner is + /// specified, the owner may cancel the vesting contract at any + /// time and withdraw unvested funds. + pub owner: Option, + /// The receiver address of the vesting tokens. + pub recipient: String, + + /// The a name or title for this payment. + pub title: String, + /// A description for the payment to provide more context. + pub description: Option, + + /// The total amount of tokens to be vested. + pub total: Uint128, + /// The type and denom of token being vested. + pub denom: UncheckedDenom, + + /// The vesting schedule, can be either `SaturatingLinear` vesting + /// (which vests evenly over time), or `PiecewiseLinear` which can + /// represent a more complicated vesting schedule. + pub schedule: Schedule, + /// The time to start vesting, or None to start vesting when the + /// contract is instantiated. `start_time` may be in the past, + /// though the contract checks that `start_time + + /// vesting_duration_seconds > now`. Otherwise, this would amount + /// to a regular fund transfer. + pub start_time: Option, + /// The length of the vesting schedule in seconds. Must be + /// non-zero, though one second vesting durations are + /// allowed. This may be combined with a `start_time` in the + /// future to create an agreement that instantly vests at a time + /// in the future, and allows the receiver to stake vesting tokens + /// before the agreement completes. + /// + /// See `suite_tests/tests.rs` + /// `test_almost_instavest_in_the_future` for an example of this. + pub vesting_duration_seconds: u64, + + /// The unbonding duration for the chain this contract is deployed + /// on. Smart contracts do not have access to this data as + /// stargate queries are disabled on most chains, and cosmwasm-std + /// provides no way to query it. + /// + /// This value being too high will cause this contract to hold + /// funds for longer than needed, this value being too low will + /// reduce the quality of error messages and require additional + /// external calculations with correct values to withdraw + /// avaliable funds from the contract. + pub unbonding_duration_seconds: u64, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + /// Fund the contract with a cw20 token. The `msg` field must have + /// the shape `{"fund":{}}`, and the amount sent must be the same + /// as the amount to be vested (as set during instantiation). + /// Anyone may call this method so long as the contract has not + /// yet been funded. + Receive(Cw20ReceiveMsg), + /// Distribute vested tokens to the vest receiver. Anyone may call + /// this method. + Distribute { + /// The amount of tokens to distribute. If none are specified + /// all claimable tokens will be distributed. + amount: Option, + }, + /// Cancels the vesting payment. The current amount vested becomes + /// the total amount that will ever vest, and all pending and + /// future staking rewards from tokens staked by this contract + /// will be sent to the owner. Tote that canceling does not impact + /// already vested tokens. + /// + /// Upon canceling, the contract will use any liquid tokens in the + /// contract to settle pending payments to the vestee, and then + /// returns the rest to the owner. Staked tokens are then split + /// between the owner and the vestee according to the number of + /// tokens that the vestee is entitled to. + /// + /// The vestee will no longer receive staking rewards after + /// cancelation, and may unbond and distribute (vested - claimed) + /// tokens at their leisure. the owner will receive staking + /// rewards and may unbond and withdraw (staked - (vested - + /// claimed)) tokens at their leisure. + Cancel {}, + /// This is translated to a + /// [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). + /// `delegator_address` is automatically filled with the current + /// contract's address. Note: this only works with the native + /// staking denom of a Cosmos chain. Only callable by Vesting + /// Payment Recipient. + Delegate { + /// The validator to delegate to. + validator: String, + /// The amount to delegate. + amount: Uint128, + }, + /// This is translated to a + /// [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L96). + /// `delegator_address` is automatically filled with the current + /// contract's address. Only callable by Vesting Payment + /// Recipient. + Redelegate { + src_validator: String, + dst_validator: String, + amount: Uint128, + }, + /// This is translated to a + /// [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). + /// `delegator_address` is automatically filled with the current + /// contract's address. Only callable by Vesting Payment + /// Recipient. + Undelegate { + /// The validator to undelegate from + validator: String, + /// The amount to delegate + amount: Uint128, + }, + /// This is translated to a + /// [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L31-L37). + /// `delegator_address` is automatically filled with the current + /// contract's address. Only callable by Vesting Payment + /// Recipient. + SetWithdrawAddress { address: String }, + /// This is translated to a + /// [MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). + /// `delegator_address` is automatically filled with the current + /// contract's address. + WithdrawDelegatorReward { + /// The validator to claim rewards for. + validator: String, + }, + /// If the owner cancels a payment and there are not enough liquid + /// tokens to settle the owner may become entitled to some number + /// of staked tokens. They may then unbond those tokens and then + /// call this method to return them. + WithdrawCanceledPayment { + /// The amount to withdraw. + amount: Option, + }, + /// Registers a slash event bonded or unbonding tokens with the + /// contract. Only callable by the owner as the contract is unable + /// to verify that the slash actually occured. The owner is + /// assumed to be honest. + /// + /// A future version of this contract may be able to + /// permissionlessly take slashing evidence: + /// + RegisterSlash { + /// The validator the slash occured for. + validator: String, + /// The time the slash event occured. Note that this is not + /// validated beyond validating that it is < now. This means + /// that if two slash events occur for a single validator, and + /// then this method is called, a dishonest sender could + /// register those two slashes as a single larger one at the + /// time of the first slash. + /// + /// The result of this is that the staked balances tracked in + /// this contract can not be relied on for accurate values in + /// the past. Staked balances will be correct at time=now. + time: Timestamp, + /// The number of tokens that THIS CONTRACT lost as a result + /// of the slash. Note that this differs from the total amount + /// slashed from the validator. + amount: Uint128, + /// If the slash happened during unbonding. Set to false in + /// the common case where the slash impacted bonding tokens. + during_unbonding: bool, + }, +} + +#[cw_serde] +pub enum ReceiveMsg { + /// Funds a vesting contract with a cw20 token + Fund {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Get the current ownership. + #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] + Ownership {}, + /// Returns information about the vesting contract and the + /// status of the payment. + #[returns(crate::vesting::Vest)] + Info {}, + /// Returns the number of tokens currently claimable by the + /// vestee. This is the minimum of the number of unstaked tokens + /// in the contract, and the number of tokens that have been + /// vested at time t. + #[returns(::cosmwasm_std::Uint128)] + Distributable { + /// The time or none to use the current time. + t: Option, + }, + /// Gets the current value of `vested(t)`. If `t` is `None`, the + /// current time is used. + #[returns(::cosmwasm_std::Uint128)] + Vested { t: Option }, + /// Gets the total amount that will ever vest, `max(vested(t))`. + /// + /// Note that if the contract is canceled at time c, this value + /// will change to `vested(c)`. Thus, it can not be assumed to be + /// constant over the contract's lifetime. + #[returns(::cosmwasm_std::Uint128)] + TotalToVest {}, + /// Gets the amount of time between the vest starting, and it + /// completing. Returns `None` if the vest has been cancelled. + #[returns(Option<::cosmwasm_std::Uint64>)] + VestDuration {}, + /// Queries information about the contract's understanding of it's + /// bonded and unbonding token balances. See the + /// `StakeTrackerQuery` in `packages/cw-stake-tracker/lib.rs` for + /// query methods and their return types. + #[returns(::cosmwasm_std::Uint128)] + Stake(StakeTrackerQuery), +} diff --git a/contracts/external/cw-vesting/src/state.rs b/contracts/external/cw-vesting/src/state.rs new file mode 100644 index 000000000..ea691bbab --- /dev/null +++ b/contracts/external/cw-vesting/src/state.rs @@ -0,0 +1,6 @@ +use cw_storage_plus::Item; + +use crate::vesting::Payment; + +pub const PAYMENT: Payment = Payment::new("vesting", "staked", "validator", "cardinality"); +pub const UNBONDING_DURATION_SECONDS: Item = Item::new("ubs"); diff --git a/contracts/external/cw-vesting/src/suite_tests/mod.rs b/contracts/external/cw-vesting/src/suite_tests/mod.rs new file mode 100644 index 000000000..26e954ed5 --- /dev/null +++ b/contracts/external/cw-vesting/src/suite_tests/mod.rs @@ -0,0 +1,12 @@ +mod suite; +mod tests; + +// 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:expr) => { + assert!(format!("{:#}", $x.unwrap_err()).contains($e)) + }; +} +pub(crate) use is_error; diff --git a/contracts/external/cw-vesting/src/suite_tests/suite.rs b/contracts/external/cw-vesting/src/suite_tests/suite.rs new file mode 100644 index 000000000..401aeb8f3 --- /dev/null +++ b/contracts/external/cw-vesting/src/suite_tests/suite.rs @@ -0,0 +1,397 @@ +use cosmwasm_std::{ + coins, testing::mock_env, Addr, BlockInfo, Decimal, Timestamp, Uint128, Uint64, Validator, +}; +use cw_multi_test::{App, BankSudo, Executor, StakingInfo, StakingSudo}; +use dao_testing::contracts::cw_vesting_contract; + +use crate::{ + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + vesting::{Schedule, Vest}, + StakeTrackerQuery, +}; + +pub(crate) struct Suite { + app: App, + pub owner: Option, + pub receiver: Addr, + pub vesting: Addr, + pub total: Uint128, +} + +pub(crate) struct SuiteBuilder { + pub instantiate: InstantiateMsg, +} + +impl Default for SuiteBuilder { + fn default() -> Self { + // default multi-test staking setup. + let staking_defaults = StakingInfo::default(); + + Self { + instantiate: InstantiateMsg { + owner: Some("owner".to_string()), + recipient: "recipient".to_string(), + title: "title".to_string(), + description: Some("description".to_string()), + total: Uint128::new(100_000_000), + denom: cw_denom::UncheckedDenom::Native(staking_defaults.bonded_denom), + schedule: Schedule::SaturatingLinear, + start_time: None, + vesting_duration_seconds: 60 * 60 * 24 * 7, // one week + unbonding_duration_seconds: staking_defaults.unbonding_time, + }, + } + } +} + +impl SuiteBuilder { + pub fn build(self) -> Suite { + let mut app = App::new(|router, api, storage| { + router + .staking + .add_validator( + api, + storage, + &mock_env().block, + Validator { + address: "validator".to_string(), + commission: Decimal::zero(), // zero percent comission to keep math simple. + max_commission: Decimal::percent(10), + max_change_rate: Decimal::percent(2), + }, + ) + .unwrap(); + router + .staking + .add_validator( + api, + storage, + &mock_env().block, + Validator { + address: "otherone".to_string(), + commission: Decimal::zero(), // zero percent comission to keep math simple. + max_commission: Decimal::percent(10), + max_change_rate: Decimal::percent(2), + }, + ) + .unwrap(); + }); + + let funds = if let cw_denom::UncheckedDenom::Native(ref denom) = self.instantiate.denom { + let funds = coins(self.instantiate.total.u128(), denom); + app.sudo( + BankSudo::Mint { + to_address: "owner".to_string(), + amount: funds.clone(), + } + .into(), + ) + .unwrap(); + funds + } else { + vec![] + }; + + let vesting_id = app.store_code(cw_vesting_contract()); + let vesting = app + .instantiate_contract( + vesting_id, + Addr::unchecked("owner"), + &self.instantiate, + &funds, + "cw_vesting", + self.instantiate.owner.clone(), + ) + .unwrap(); + + Suite { + app, + owner: self.instantiate.owner.map(Addr::unchecked), + total: self.instantiate.total, + receiver: Addr::unchecked(self.instantiate.recipient), + vesting, + } + } + + pub fn with_start_time(mut self, t: Timestamp) -> Self { + self.instantiate.start_time = Some(t); + self + } + + pub fn with_vesting_duration(mut self, duration_seconds: u64) -> Self { + self.instantiate.vesting_duration_seconds = duration_seconds; + self + } + + pub fn with_curve(mut self, s: Schedule) -> Self { + self.instantiate.schedule = s; + self + } +} + +impl Suite { + pub fn time(&self) -> Timestamp { + self.app.block_info().time + } + + pub fn a_second_passes(&mut self) { + self.app.update_block(|b| b.time = b.time.plus_seconds(1)) + } + + pub fn a_day_passes(&mut self) { + self.app + .update_block(|b| b.time = b.time.plus_seconds(60 * 60 * 24)) + } + + pub fn a_week_passes(&mut self) { + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + } + + pub fn what_block_is_it(&self) -> BlockInfo { + self.app.block_info() + } + + pub fn slash(&mut self, percent: u64) { + self.app + .sudo( + StakingSudo::Slash { + validator: "validator".to_string(), + percentage: Decimal::percent(percent), + } + .into(), + ) + .unwrap(); + } + + pub fn process_unbonds(&mut self) { + self.app.sudo(StakingSudo::ProcessQueue {}.into()).unwrap(); + } +} + +// execute +impl Suite { + pub fn distribute>( + &mut self, + sender: S, + amount: Option, + ) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.vesting.clone(), + &ExecuteMsg::Distribute { amount }, + &[], + ) + .map(|_| ()) + } + + pub fn cancel>(&mut self, sender: S) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.vesting.clone(), + &ExecuteMsg::Cancel {}, + &[], + ) + .map(|_| ()) + } + + pub fn delegate(&mut self, amount: Uint128) -> anyhow::Result<()> { + self.app + .execute_contract( + self.receiver.clone(), + self.vesting.clone(), + &ExecuteMsg::Delegate { + validator: "validator".to_string(), + amount, + }, + &[], + ) + .map(|_| ()) + } + + pub fn redelegate(&mut self, amount: Uint128, to_other_one: bool) -> anyhow::Result<()> { + let (src_validator, dst_validator) = if to_other_one { + ("validator".to_string(), "otherone".to_string()) + } else { + ("otherone".to_string(), "validator".to_string()) + }; + self.app + .execute_contract( + self.receiver.clone(), + self.vesting.clone(), + &ExecuteMsg::Redelegate { + src_validator, + dst_validator, + amount, + }, + &[], + ) + .map(|_| ()) + } + + pub fn undelegate>( + &mut self, + sender: S, + amount: Uint128, + ) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.vesting.clone(), + &ExecuteMsg::Undelegate { + validator: "validator".to_string(), + amount, + }, + &[], + ) + .map(|_| ()) + } + + pub fn withdraw_delegator_reward(&mut self, validator: &str) -> anyhow::Result<()> { + self.app + .execute_contract( + self.receiver.clone(), + self.vesting.clone(), + &ExecuteMsg::WithdrawDelegatorReward { + validator: validator.to_string(), + }, + &[], + ) + .map(|_| ()) + } + + pub fn withdraw_canceled(&mut self, amount: Option) -> anyhow::Result<()> { + self.app + .execute_contract( + // anyone may call this method on a canceled vesting contract + Addr::unchecked("random"), + self.vesting.clone(), + &ExecuteMsg::WithdrawCanceledPayment { amount }, + &[], + ) + .map(|_| ()) + } + + pub fn set_withdraw_address>( + &mut self, + sender: S, + receiver: S, + ) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.vesting.clone(), + &ExecuteMsg::SetWithdrawAddress { + address: receiver.into(), + }, + &[], + ) + .map(|_| ()) + } + + pub fn register_bonded_slash>( + &mut self, + sender: S, + amount: Uint128, + time: Timestamp, + ) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.vesting.clone(), + &ExecuteMsg::RegisterSlash { + validator: "validator".to_string(), + time, + amount, + during_unbonding: false, + }, + &[], + ) + .map(|_| ()) + } + + pub fn register_unbonding_slash>( + &mut self, + sender: S, + amount: Uint128, + time: Timestamp, + ) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.vesting.clone(), + &ExecuteMsg::RegisterSlash { + validator: "validator".to_string(), + time, + amount, + during_unbonding: true, + }, + &[], + ) + .map(|_| ()) + } +} + +// query +impl Suite { + pub fn query_vest(&self) -> Vest { + self.app + .wrap() + .query_wasm_smart(&self.vesting, &QueryMsg::Info {}) + .unwrap() + } + + pub fn query_distributable(&self) -> Uint128 { + self.app + .wrap() + .query_wasm_smart(&self.vesting, &QueryMsg::Distributable { t: None }) + .unwrap() + } + + pub fn query_receiver_vesting_token_balance(&self) -> Uint128 { + let vest = self.query_vest(); + self.query_vesting_token_balance(vest.recipient) + } + + pub fn query_vesting_token_balance>(&self, who: S) -> Uint128 { + let vest = self.query_vest(); + vest.denom + .query_balance(&self.app.wrap(), &Addr::unchecked(who.into())) + .unwrap() + } + + pub fn query_stake(&self, q: StakeTrackerQuery) -> Uint128 { + self.app + .wrap() + .query_wasm_smart(&self.vesting, &QueryMsg::Stake(q)) + .unwrap() + } + + pub fn query_vested(&self, t: Option) -> Uint128 { + self.app + .wrap() + .query_wasm_smart(&self.vesting, &QueryMsg::Vested { t }) + .unwrap() + } + + pub fn query_total_to_vest(&self) -> Uint128 { + self.app + .wrap() + .query_wasm_smart(&self.vesting, &QueryMsg::TotalToVest {}) + .unwrap() + } + + pub fn query_duration(&self) -> Option { + self.app + .wrap() + .query_wasm_smart(&self.vesting, &QueryMsg::VestDuration {}) + .unwrap() + } +} diff --git a/contracts/external/cw-vesting/src/suite_tests/tests.rs b/contracts/external/cw-vesting/src/suite_tests/tests.rs new file mode 100644 index 000000000..f93656c64 --- /dev/null +++ b/contracts/external/cw-vesting/src/suite_tests/tests.rs @@ -0,0 +1,675 @@ +use cosmwasm_std::{Timestamp, Uint128, Uint64}; +use cw_multi_test::App; +use cw_ownable::OwnershipError; + +use crate::{ + vesting::{Schedule, Status}, + ContractError, +}; + +use super::{is_error, suite::SuiteBuilder}; + +#[test] +fn test_suite_instantiate() { + SuiteBuilder::default().build(); +} + +/// Can not have a start time in the past such that the vest would +/// complete instantly. +#[test] +#[should_panic(expected = "this vesting contract would complete instantly")] +fn test_no_past_instavest() { + SuiteBuilder::default() + .with_start_time(Timestamp::from_seconds(0)) + .with_vesting_duration(10) + .build(); +} + +#[test] +#[should_panic(expected = "this vesting contract would complete instantly")] +fn test_no_duration_instavest() { + SuiteBuilder::default() + .with_start_time(Timestamp::from_seconds(0)) + .with_vesting_duration(0) + .build(); +} + +#[test] +#[should_panic(expected = "this vesting contract would complete instantly")] +fn test_no_instavest_in_the_future() { + let default_start_time = App::default().block_info().time; + + SuiteBuilder::default() + .with_start_time(default_start_time.plus_seconds(60 * 60 * 24)) + .with_vesting_duration(0) + .build(); +} + +/// Attempting to distribute more tokens than are claimable is not +/// allowed. +#[test] +fn test_distribute_more_than_claimable() { + let mut suite = SuiteBuilder::default().build(); + suite.a_day_passes(); + + let res = suite.distribute(suite.receiver.clone(), Some(suite.total)); + is_error!( + res, + ContractError::InvalidWithdrawal { + request: suite.total, + claimable: Uint128::new(100_000_000 / 7), + } + .to_string() + .as_str() + ) +} + +/// Attempting to distribute while nothing is claimable is not +/// allowed. +#[test] +fn test_distribute_nothing_claimable() { + let mut suite = SuiteBuilder::default().build(); + + // two days pass, 2/7ths of rewards avaliable. + suite.a_day_passes(); + suite.a_day_passes(); + + // anyone can call distribute. + suite.distribute("random", None).unwrap(); + + let balance = suite.query_receiver_vesting_token_balance(); + assert_eq!(balance, suite.total.multiply_ratio(2u128, 7u128)); + + let res = suite.distribute("random", None); + + is_error!( + res, + ContractError::InvalidWithdrawal { + request: Uint128::zero(), + claimable: Uint128::zero(), + } + .to_string() + .as_str() + ) +} + +/// Distributing long after the vest has totally vested is fine. +#[test] +fn test_distribute_post_completion() { + let mut suite = SuiteBuilder::default().build(); + + suite.a_day_passes(); + + suite.distribute("random", None).unwrap(); + let balance = suite.query_receiver_vesting_token_balance(); + assert_eq!(balance, suite.total.multiply_ratio(1u128, 7u128)); + + suite.a_week_passes(); + suite.a_week_passes(); + + suite.distribute("violet", None).unwrap(); + let balance = suite.query_receiver_vesting_token_balance(); + assert_eq!(balance, suite.total); +} + +/// This cancels a vesting contract at a time when it has insufficent +/// liquid tokens to settle the vest receiver. In a situation like +/// this, it should settle the receiver as much as possible, allow +/// anyone to unstake, and allow the receiver and owner to claim their +/// tokens once all of them have unstaked. +#[test] +fn test_cancel_can_not_settle_receiver() { + let mut suite = SuiteBuilder::default().build(); + + // delegate all but ten tokens (in terms of non-micro + // denominations). + suite.delegate(Uint128::new(90_000_000)).unwrap(); + + suite.a_day_passes(); + + // withdraw rewards before cancelation. not doing this would cause + // the rewards withdrawal address to be updated to the owner and + // thus entitle them to the rewards. + suite.withdraw_delegator_reward("validator").unwrap(); + + suite.cancel(suite.owner.clone().unwrap()).unwrap(); + + suite.a_day_passes(); + + // now that the vest is canceled, these rewards should go to the + // owner. + suite.withdraw_delegator_reward("validator").unwrap(); + + let owner_rewards = suite.query_vesting_token_balance(suite.owner.clone().unwrap()); + let expected_staking_rewards = Uint128::new(90_000_000) + .multiply_ratio(1u128, 10u128) // default rewards rate is 10%/yr + .multiply_ratio(1u128, 365u128); + assert_eq!(owner_rewards, expected_staking_rewards); + + // receiver should have received the same amount of staking + // rewards as the owner, as well as the liquid tokens in the + // contract at the time of cancelation. + let receiver_balance = suite.query_receiver_vesting_token_balance(); + assert_eq!(receiver_balance, owner_rewards + Uint128::new(10_000_000)); + + // contract is canceled so anyone can undelegate. + suite + .undelegate("random", Uint128::new(90_000_000)) + .unwrap(); + + // let tokens unstake. default unstaking period is ten seconds. + suite.a_day_passes(); + suite.process_unbonds(); + + suite.withdraw_canceled(None).unwrap(); + suite.distribute("random", None).unwrap(); + + // vestee should now have received all tokens they are entitled to + // having vested for one day. + let balance = suite.query_receiver_vesting_token_balance(); + assert_eq!( + balance, + suite.total.multiply_ratio(1u128, 7u128) + expected_staking_rewards + ); + + let owner = suite.query_vesting_token_balance(suite.owner.clone().unwrap()); + assert_eq!( + owner, + suite.total - suite.total.multiply_ratio(1u128, 7u128) + expected_staking_rewards + ); +} + +#[test] +fn test_set_withdraw_address_permissions() { + let mut suite = SuiteBuilder::default().build(); + + // delegate all but ten tokens (in terms of non-micro + // denominations). + suite.delegate(Uint128::new(90_000_000)).unwrap(); + + suite.a_day_passes(); + + // owner may not update withdraw address if vesting is not canceled. + let res = + suite.set_withdraw_address(suite.owner.clone().unwrap().to_string().as_str(), "random"); + is_error!(res, ContractError::NotReceiver.to_string().as_str()); + + // non-owner can not cancel. + let res = suite.cancel("random"); + is_error!( + res, + ContractError::Ownable(OwnershipError::NotOwner) + .to_string() + .as_str() + ); + + suite.cancel(suite.owner.clone().unwrap()).unwrap(); + + let res = suite.set_withdraw_address(suite.owner.clone().unwrap(), suite.vesting.clone()); + is_error!(res, ContractError::SelfWithdraw.to_string().as_str()); +} + +/// Canceling a completed vest is fine. +#[test] +fn test_cancel_completed_vest() { + let mut suite = SuiteBuilder::default().build(); + suite.a_week_passes(); + suite.distribute("random", None).unwrap(); + suite.cancel(suite.owner.clone().unwrap()).unwrap(); + assert_eq!( + suite.query_vest().status, + Status::Canceled { + owner_withdrawable: Uint128::zero() + } + ) +} + +#[test] +fn test_redelegation() { + let expected_balance = { + // same operation as below, but without a redelegation. + let mut suite = SuiteBuilder::default().build(); + suite.delegate(Uint128::new(100_000_000)).unwrap(); + suite.a_day_passes(); + suite.a_day_passes(); + suite + .undelegate(suite.receiver.clone(), Uint128::new(25_000_000)) + .unwrap(); + + suite.a_day_passes(); + suite.process_unbonds(); + + suite.distribute("random", None).unwrap(); + suite.withdraw_delegator_reward("validator").unwrap(); + + suite.query_receiver_vesting_token_balance() + }; + + let expected_staking_rewards = Uint128::new(100_000_000) + .multiply_ratio(1u128, 10u128) + .multiply_ratio(2u128, 365u128) + + Uint128::new(75_000_000) + .multiply_ratio(1u128, 10u128) + .multiply_ratio(1u128, 365u128); + + assert_eq!( + expected_staking_rewards, + expected_balance - Uint128::new(25_000_001) // rounding 🤷 + ); + + let mut suite = SuiteBuilder::default().build(); + + // delegate all the tokens in the contract. + suite.delegate(Uint128::new(100_000_000)).unwrap(); + + suite.a_day_passes(); // collect rewards + + // redelegate half of the tokens to the other validator. + suite.redelegate(Uint128::new(50_000_000), true).unwrap(); + + suite.a_day_passes(); + + // undelegate from the first validator. + suite + .undelegate(suite.receiver.clone(), Uint128::new(25_000_000)) + .unwrap(); + + suite.a_day_passes(); + suite.process_unbonds(); + + suite.distribute("random", None).unwrap(); + suite.withdraw_delegator_reward("validator").unwrap(); + suite.withdraw_delegator_reward("otherone").unwrap(); + + let balance = suite.query_receiver_vesting_token_balance(); + + // for reasons beyond me, staking rewards accrue differently when + // the redelegate happens. i am unsure why and this test is more + // concerned with them working than the absolute numbers, so >=. + assert!(balance >= expected_balance) +} + +/// Creates a vesting contract with a start time in the past s.t. the +/// vest immediately completes. +#[test] +fn test_start_time_in_the_past() { + let default_start_time = App::default().block_info().time; + + let mut suite = SuiteBuilder::default() + .with_start_time(default_start_time.minus_seconds(100)) + .build(); + + suite.a_week_passes(); + + // distributing over two TXns shouldn't matter. + suite + .distribute("lerandom", Some(Uint128::new(10_000_000))) + .unwrap(); + suite.distribute("lerandom", None).unwrap(); + let balance = suite.query_receiver_vesting_token_balance(); + assert_eq!(balance, Uint128::new(100_000_000)); +} + +/// 1. Vestee is vesting 100 tokens +/// 2. Delegate 50 to validator +/// 3. Vestee looses 10 tokens to a validator slash +/// 4. Vestee slash reduces the amount the receiver may claim +#[test] +fn test_simple_slash() { + let mut suite = SuiteBuilder::default().build(); + suite.delegate(Uint128::new(50_000_000)).unwrap(); + + let vest = suite.query_vest(); + assert_eq!(vest.slashed, Uint128::zero()); + + let pre_slash_distributable = suite.query_distributable(); + + // because no time has passed, the slash amount is > the + // distributable amount. this should not cause an overflow in + // future calculations. + suite.slash(20); // 20% slash should slash 10_000_000 tokens. + let time = suite.time(); + + // Only the owner can register a slash. + let receiver = suite.receiver.clone(); + let owner = suite.owner.clone().unwrap(); + let res = suite.register_bonded_slash(&receiver, Uint128::new(10_000_000), time); + is_error!(res, OwnershipError::NotOwner.to_string().as_str()); + + suite + .register_bonded_slash(&owner, Uint128::new(10_000_000), time) + .unwrap(); + + let vest = suite.query_vest(); + assert_eq!(vest.slashed, Uint128::new(10_000_000)); + let distributable = suite.query_distributable(); + assert_eq!( + distributable, + pre_slash_distributable.saturating_sub(Uint128::new(10_000_000)) + ); + + assert_eq!(distributable, Uint128::zero()); +} + +/// A slash that is registered in the canceled state should count +/// against the owner even if the time of the slash was during the +/// Funded state. Owners should take care to register slashes before +/// canceling the contract. +#[test] +fn test_slash_while_cancelled_counts_against_owner() { + let mut suite = SuiteBuilder::default().build(); + suite.delegate(Uint128::new(50_000_000)).unwrap(); + + suite.a_day_passes(); + + let slash_time = suite.time(); + suite.slash(20); + + // on cancel all liquid tokens are sent to the receiver to make + // them whole. the slash has not been registered so this is an + // overpayment. + let distributable = suite.query_distributable(); + + suite.cancel(suite.owner.clone().unwrap()).unwrap(); + + let balance = suite.query_receiver_vesting_token_balance(); + assert_eq!(balance, distributable); + + let vest = suite.query_vest(); + let Status::Canceled { owner_withdrawable: pre_slash } = vest.status else { panic!("should be canceled") }; + + // register the slash. even though the time of the slash was + // during the vest, the contract should deduct this from + // owner_withdrawable as the contract is in a canceled state. + suite + .register_bonded_slash( + suite.owner.clone().unwrap(), + Uint128::new(10_000_000), + slash_time, + ) + .unwrap(); + + let vest = suite.query_vest(); + let Status::Canceled { owner_withdrawable } = vest.status else { panic!("should be canceled") }; + assert_eq!(pre_slash - Uint128::new(10_000_000), owner_withdrawable); +} + +/// Simple slash while tokens are unbonding and no cancelation. +#[test] +fn test_slash_during_unbonding() { + let mut suite = SuiteBuilder::default().build(); + suite.delegate(Uint128::new(50_000_000)).unwrap(); + + suite.a_second_passes(); + + suite + .undelegate(suite.receiver.clone(), Uint128::new(50_000_000)) + .unwrap(); + + let pre_slash_distributable = suite.query_distributable(); + + suite.slash(20); // 20% slash should slash 10_000_000 tokens. + let time = suite.time(); + + let owner = suite.owner.clone().unwrap(); + suite + .register_unbonding_slash(&owner, Uint128::new(10_000_000), time) + .unwrap(); + + let vest = suite.query_vest(); + assert_eq!(vest.slashed, Uint128::new(10_000_000)); + let distributable = suite.query_distributable(); + assert_eq!( + distributable, + pre_slash_distributable.saturating_sub(Uint128::new(10_000_000)) + ); + + suite.a_week_passes(); + suite.a_week_passes(); + suite.process_unbonds(); + + suite.distribute("lerandom", None).unwrap(); + assert_eq!( + suite.query_receiver_vesting_token_balance(), + Uint128::new(90_000_000) // 10 slashed + ); + + // the staking implementation doesn't slash unbonding tokens in cw-multi-test.. + + // assert_eq!( + // suite.query_vesting_token_balance(suite.vesting.clone()), + // Uint128::zero() + // ) +} + +/// If the owner intentionally doesn't register a slash until they +/// have already withdrawn their tokens, the slash will be forced to +/// go to the receiver. The contract should handle this gracefully and +/// cause no overflows. +#[test] +fn test_owner_registers_slash_after_withdrawal() { + let mut suite = SuiteBuilder::default().build(); + suite.delegate(Uint128::new(100_000_000)).unwrap(); + suite.a_day_passes(); + + suite.cancel(suite.owner.clone().unwrap()).unwrap(); + + let vested = suite.query_vest().vested(suite.time()); + + // at this point 1/7th of the vest has elapsed, so the receiver + // should be entitled to 1/7th regardless of a slash occuring as + // the slash occures while the contract is in the canceled state. + // + // instead, the owner undelegates the remaining tokens, claims all + // of them, and then registers the slash. as the slash as + // registered too late, this will result in the receiver not + // getting their tokens. + suite.slash(90); // 90% slash + let time = suite.time(); + + suite + .undelegate(suite.owner.clone().unwrap(), Uint128::new(10_000_000)) + .unwrap(); + + suite.a_day_passes(); + suite.process_unbonds(); + + suite.withdraw_canceled(None).unwrap(); + assert_eq!( + suite.query_vesting_token_balance(suite.owner.clone().unwrap()), + Uint128::new(10_000_000) + ); + + suite + .register_bonded_slash(suite.owner.clone().unwrap(), Uint128::new(90_000_000), time) + .unwrap(); + assert_eq!(suite.query_distributable(), Uint128::zero()); + assert_eq!( + vested, + Uint128::new(100_000_000).multiply_ratio(1u128, 7u128) + ); +} + +/// Tests a one second vesting duration and a start time one week in +/// the future. Before the vest has completed, the receier should be +/// allowed to bond tokens and receive staking rewards, but should not +/// be able to claim any tokens. +#[test] +fn test_almost_instavest_in_the_future() { + let default_start_time = App::default().block_info().time; + + let mut suite = SuiteBuilder::default() + .with_start_time(default_start_time.plus_seconds(60 * 60 * 24 * 7)) + .with_vesting_duration(1) + .build(); + + suite.delegate(Uint128::new(100_000_000)).unwrap(); + let distributable = suite.query_distributable(); + assert_eq!(distributable, Uint128::zero()); + + // five days pass. + suite.a_day_passes(); + suite.a_day_passes(); + suite.a_day_passes(); + suite.a_day_passes(); + suite.a_day_passes(); + + let balance_pre_claim = suite.query_receiver_vesting_token_balance(); + suite.withdraw_delegator_reward("validator").unwrap(); + let balance_post_claim = suite.query_receiver_vesting_token_balance(); + assert!(balance_post_claim > balance_pre_claim); + + suite + .undelegate(suite.receiver.clone(), Uint128::new(100_000_000)) + .unwrap(); + + // seven days have passed. one second remaining for vest + // completion. + suite.a_day_passes(); + suite.a_day_passes(); + suite.process_unbonds(); + + let distributable = suite.query_distributable(); + assert_eq!(distributable, Uint128::zero()); + let res = suite.distribute("lerandom", None); + is_error!( + res, + ContractError::InvalidWithdrawal { + request: Uint128::zero(), + claimable: Uint128::zero() + } + .to_string() + .as_str() + ); + + // a second passes, the vest is now complete. + suite.a_second_passes(); + + let distributable = suite.query_distributable(); + assert_eq!(distributable, Uint128::new(100_000_000)); + suite + .distribute("lerandom", Some(Uint128::new(100_000_000))) + .unwrap(); + let balance = suite.query_receiver_vesting_token_balance(); + assert_eq!(balance, balance_post_claim + Uint128::new(100_000_000)); +} + +/// Test that the stake tracker correctly tracks stake during bonding, +/// unbonding, and slashing. +#[test] +fn test_stake_query() { + use crate::StakeTrackerQuery; + + let mut suite = SuiteBuilder::default().build(); + + let total_staked = suite.query_stake(StakeTrackerQuery::TotalStaked { + t: suite.what_block_is_it().time, + }); + assert_eq!(total_staked, Uint128::zero()); + + suite.delegate(Uint128::new(123_456)).unwrap(); + + let val_staked = suite.query_stake(StakeTrackerQuery::ValidatorStaked { + t: suite.what_block_is_it().time, + validator: "validator".to_string(), + }); + assert_eq!(val_staked, Uint128::new(123_456)); + + suite.slash(50); + suite + .register_bonded_slash( + suite.owner.clone().unwrap().as_str(), + Uint128::new(61_728), + suite.what_block_is_it().time, + ) + .unwrap(); + + let val_staked = suite.query_stake(StakeTrackerQuery::ValidatorStaked { + t: suite.what_block_is_it().time, + validator: "validator".to_string(), + }); + assert_eq!(val_staked, Uint128::new(61_728)); + + suite + .undelegate(suite.receiver.clone(), Uint128::new(61_728)) + .unwrap(); + + let val_staked = suite.query_stake(StakeTrackerQuery::ValidatorStaked { + t: suite.what_block_is_it().time, + validator: "validator".to_string(), + }); + assert_eq!(val_staked, Uint128::new(61_728)); + + suite.slash(50); + suite + .register_unbonding_slash( + suite.owner.clone().unwrap().as_str(), + Uint128::new(30_864), + suite.what_block_is_it().time, + ) + .unwrap(); + + let total_staked = suite.query_stake(StakeTrackerQuery::TotalStaked { + t: suite.what_block_is_it().time, + }); + assert_eq!(total_staked, Uint128::new(30_864)); + let val_staked = suite.query_stake(StakeTrackerQuery::ValidatorStaked { + t: suite.what_block_is_it().time, + validator: "validator".to_string(), + }); + assert_eq!(val_staked, Uint128::new(30_864)); + let cardinality = suite.query_stake(StakeTrackerQuery::Cardinality { + t: suite.what_block_is_it().time, + }); + assert_eq!(cardinality, Uint128::new(1)); +} + +/// Basic checks on piecewise vests and queries. +#[test] +fn test_piecewise_and_queries() { + let mut suite = SuiteBuilder::default() + .with_start_time(SuiteBuilder::default().build().what_block_is_it().time) + .with_curve(Schedule::PiecewiseLinear(vec![ + // allows + // for zero start values. + (1, Uint128::new(0)), + (2, Uint128::new(40_000_000)), + (3, Uint128::new(100_000_000)), + ])) + .build(); + + let duration = suite.query_duration(); + assert_eq!(duration.unwrap(), Uint64::new(2)); + + let distributable = suite.query_distributable(); + assert_eq!(distributable, Uint128::new(0)); + + suite.a_second_passes(); + + let distributable = suite.query_distributable(); + assert_eq!(distributable, Uint128::new(0)); + + suite.a_second_passes(); + + let distributable = suite.query_distributable(); + assert_eq!(distributable, Uint128::new(40_000_000)); + + suite.delegate(Uint128::new(80_000_000)).unwrap(); + + let distributable = suite.query_distributable(); + assert_eq!(distributable, Uint128::new(20_000_000)); + let vested = suite.query_vested(None); + assert_eq!(vested, Uint128::new(40_000_000)); + + let total = suite.query_total_to_vest(); + assert_eq!(total, Uint128::new(100_000_000)); + + suite.cancel(suite.owner.clone().unwrap()).unwrap(); + + let total = suite.query_total_to_vest(); + assert_eq!(total, Uint128::new(40_000_000)); + + // canceled, duration no longer has a meaning. + let duration = suite.query_duration(); + assert_eq!(duration, None); +} diff --git a/contracts/external/cw-vesting/src/tests.rs b/contracts/external/cw-vesting/src/tests.rs new file mode 100644 index 000000000..c9f78099c --- /dev/null +++ b/contracts/external/cw-vesting/src/tests.rs @@ -0,0 +1,717 @@ +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{coins, to_binary, Addr, Coin, Decimal, Empty, Uint128, Validator}; +use cw20::{Cw20Coin, Cw20ExecuteMsg, Cw20ReceiveMsg}; +use cw_denom::{CheckedDenom, UncheckedDenom}; +use cw_multi_test::{ + App, AppBuilder, BankSudo, Contract, ContractWrapper, Executor, StakingInfo, SudoMsg, +}; +use cw_ownable::Action; +use dao_testing::contracts::cw20_base_contract; + +use crate::contract::{execute, execute_receive_cw20}; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, ReceiveMsg}; +use crate::state::PAYMENT; +use crate::vesting::{Schedule, Status, Vest, VestInit}; +use crate::ContractError; + +const ALICE: &str = "alice"; +const BOB: &str = "bob"; +const INITIAL_BALANCE: u128 = 1000000000; +const TOTAL_VEST: u128 = 1000000; +const OWNER: &str = "owner"; +const NATIVE_DENOM: &str = "ujuno"; + +fn cw_vesting_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn get_vesting_payment(app: &App, cw_vesting_addr: Addr) -> Vest { + app.wrap() + .query_wasm_smart(cw_vesting_addr, &QueryMsg::Info {}) + .unwrap() +} + +fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn get_balance_native, U: Into>( + app: &App, + address: T, + denom: U, +) -> Uint128 { + app.wrap().query_balance(address, denom).unwrap().amount +} + +pub fn setup_app() -> App { + let mut app = App::default(); + + // Mint Alice and Bob native tokens + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: ALICE.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: BOB.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: OWNER.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + + app +} + +pub fn setup_contracts(app: &mut App) -> (Addr, u64, u64) { + let cw20_code_id = app.store_code(cw20_base_contract()); + let cw_vesting_code_id = app.store_code(cw_vesting_contract()); + + // Instantiate cw20 contract with balances for Alice and Bob + let cw20_addr = app + .instantiate_contract( + cw20_code_id, + Addr::unchecked(OWNER), + &cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![ + Cw20Coin { + address: ALICE.to_string(), + amount: Uint128::new(INITIAL_BALANCE), + }, + Cw20Coin { + address: BOB.to_string(), + amount: Uint128::new(INITIAL_BALANCE), + }, + Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(INITIAL_BALANCE), + }, + ], + mint: None, + marketing: None, + }, + &[], + "cw20-base", + None, + ) + .unwrap(); + + (cw20_addr, cw20_code_id, cw_vesting_code_id) +} + +#[cfg(test)] +impl Default for InstantiateMsg { + fn default() -> Self { + Self { + owner: Some(OWNER.to_string()), + recipient: BOB.to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: Uint128::new(TOTAL_VEST), + // cw20 normally first contract instantaited + denom: UncheckedDenom::Cw20("contract0".to_string()), + schedule: Schedule::SaturatingLinear, + start_time: None, + vesting_duration_seconds: 604800, // one week + unbonding_duration_seconds: 2592000, // 30 days + } + } +} + +struct TestCase { + cw20_addr: Addr, + cw_vesting_addr: Addr, + recipient: Addr, + vesting_payment: Vest, +} + +fn setup_test_case(app: &mut App, msg: InstantiateMsg, funds: &[Coin]) -> TestCase { + let (cw20_addr, _, cw_vesting_code_id) = setup_contracts(app); + + // Instantiate cw-vesting contract + let cw_vesting_addr = app + .instantiate_contract( + cw_vesting_code_id, + Addr::unchecked(OWNER), + &msg, + funds, + "cw-vesting", + None, + ) + .unwrap(); + + let vesting_payment = match msg.denom { + UncheckedDenom::Cw20(ref cw20_addr) => { + let msg = Cw20ExecuteMsg::Send { + contract: cw_vesting_addr.to_string(), + amount: msg.total, + msg: to_binary(&ReceiveMsg::Fund {}).unwrap(), + }; + app.execute_contract( + Addr::unchecked(OWNER), + Addr::unchecked(cw20_addr.clone()), + &msg, + &[], + ) + .unwrap(); + + get_vesting_payment(app, cw_vesting_addr.clone()) + } + UncheckedDenom::Native(_) => get_vesting_payment(app, cw_vesting_addr.clone()), + }; + + TestCase { + cw20_addr, + cw_vesting_addr, + recipient: Addr::unchecked(msg.recipient), + vesting_payment, + } +} + +#[test] +fn test_happy_cw20_path() { + let mut app = setup_app(); + + let TestCase { + cw20_addr, + cw_vesting_addr, + recipient: bob, + vesting_payment, + .. + } = setup_test_case(&mut app, InstantiateMsg::default(), &[]); + + // Check Vesting Payment was created correctly + assert_eq!(vesting_payment.status, Status::Funded); + assert_eq!(vesting_payment.claimed, Uint128::zero()); + assert_eq!( + vesting_payment.vested(app.block_info().time), + Uint128::zero() + ); + + // No time has passed, so nothing is withdrawable. + let err: ContractError = app + .execute_contract( + bob.clone(), + cw_vesting_addr.clone(), + &ExecuteMsg::Distribute { amount: None }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::InvalidWithdrawal { + request: Uint128::zero(), + claimable: Uint128::zero() + } + ); + + // Advance the clock by 1/2 the vesting period. + app.update_block(|block| { + block.time = block.time.plus_seconds(604800 / 2); + }); + + // Distribute, expect to receive 50% of funds. + app.execute_contract( + bob, + cw_vesting_addr, + &ExecuteMsg::Distribute { amount: None }, + &[], + ) + .unwrap(); + + // Owner has funded the contract and down + assert_eq!( + get_balance_cw20(&app, cw20_addr.clone(), OWNER), + Uint128::new(INITIAL_BALANCE - TOTAL_VEST) + ); + + // Bob has claimed vested funds and is up + assert_eq!( + get_balance_cw20(&app, cw20_addr, BOB), + Uint128::new(INITIAL_BALANCE) + Uint128::new(TOTAL_VEST / 2) + ); +} + +#[test] +fn test_happy_native_path() { + let mut app = setup_app(); + + let msg = InstantiateMsg { + denom: UncheckedDenom::Native(NATIVE_DENOM.to_string()), + ..Default::default() + }; + + let TestCase { + cw_vesting_addr, + recipient: bob, + vesting_payment, + .. + } = setup_test_case(&mut app, msg, &coins(TOTAL_VEST, NATIVE_DENOM)); + + assert_eq!(vesting_payment.status, Status::Funded); + assert_eq!(vesting_payment.claimed, Uint128::zero()); + assert_eq!( + vesting_payment.vested(app.block_info().time), + Uint128::zero() + ); + + // No time has passed, so nothing is withdrawable. + let err: ContractError = app + .execute_contract( + bob.clone(), + cw_vesting_addr.clone(), + &ExecuteMsg::Distribute { amount: None }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::InvalidWithdrawal { + request: Uint128::zero(), + claimable: Uint128::zero() + } + ); + + // Advance the clock by 1/2 the vesting period. + app.update_block(|block| { + block.time = block.time.plus_seconds(604800 / 2); + }); + + // Distribute, expect to receive 50% of funds. + app.execute_contract( + bob, + cw_vesting_addr, + &ExecuteMsg::Distribute { amount: None }, + &[], + ) + .unwrap(); + + // Owner has funded the contract and down 1000 + assert_eq!( + get_balance_native(&app, OWNER, NATIVE_DENOM), + Uint128::new(INITIAL_BALANCE - TOTAL_VEST) + ); + // Bob has claimed vested funds and is up 250 + assert_eq!( + get_balance_native(&app, BOB, NATIVE_DENOM), + Uint128::new(INITIAL_BALANCE) + Uint128::new(TOTAL_VEST / 2) + ); +} + +#[test] +fn test_staking_rewards_go_to_receiver() { + let validator = Validator { + address: "testvaloper1".to_string(), + commission: Decimal::percent(1), + max_commission: Decimal::percent(100), + max_change_rate: Decimal::percent(1), + }; + + let mut app = AppBuilder::default().build(|router, api, storage| { + router + .staking + .setup( + storage, + StakingInfo { + bonded_denom: NATIVE_DENOM.to_string(), + unbonding_time: 60, + /// Interest rate per year (60 * 60 * 24 * 365 seconds) + apr: Decimal::percent(10), + }, + ) + .unwrap(); + router + .staking + .add_validator(api, storage, &mock_env().block, validator) + .unwrap(); + }); + + let vesting_id = app.store_code(cw_vesting_contract()); + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: OWNER.to_string(), + amount: coins(100, NATIVE_DENOM), + })) + .unwrap(); + + let msg = InstantiateMsg { + denom: UncheckedDenom::Native(NATIVE_DENOM.to_string()), + total: Uint128::new(100), + ..Default::default() + }; + + let vesting = app + .instantiate_contract( + vesting_id, + Addr::unchecked(OWNER), + &msg, + &coins(100, NATIVE_DENOM), + "cw-vesting", + None, + ) + .unwrap(); + + // delegate all of the tokens to the validaor. + app.execute_contract( + Addr::unchecked(BOB), + vesting.clone(), + &ExecuteMsg::Delegate { + validator: "testvaloper1".to_string(), + amount: Uint128::new(100), + }, + &[], + ) + .unwrap(); + + let balance = get_balance_native(&app, BOB, NATIVE_DENOM); + assert_eq!(balance.u128(), 0); + + // A year passes. + app.update_block(|block| block.time = block.time.plus_seconds(60 * 60 * 24 * 365)); + + app.execute_contract( + Addr::unchecked(BOB), + vesting, + &ExecuteMsg::WithdrawDelegatorReward { + validator: "testvaloper1".to_string(), + }, + &[], + ) + .unwrap(); + + let balance = get_balance_native(&app, BOB, NATIVE_DENOM); + assert_eq!(balance.u128(), 9); // 10% APY, 1% comission, 100 staked, one year elapsed. +} + +#[test] +fn test_cancel_vesting() { + let mut app = setup_app(); + + let TestCase { + cw_vesting_addr, .. + } = setup_test_case(&mut app, InstantiateMsg::default(), &[]); + + // Non-owner can't cancel + let err: ContractError = app + .execute_contract( + Addr::unchecked(ALICE), + cw_vesting_addr.clone(), + &ExecuteMsg::Cancel {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::Ownable(cw_ownable::OwnershipError::NotOwner) + ); + + // Advance the clock by 1/2 the vesting period. + app.update_block(|block| { + block.time = block.time.plus_seconds(604800 / 2); + }); + + // Owner DAO cancels vesting contract. All tokens are liquid so + // everything settles instantly. + app.execute_contract( + Addr::unchecked(OWNER), + cw_vesting_addr.clone(), + &ExecuteMsg::Cancel {}, + &[], + ) + .unwrap(); + + // Can't distribute as tokens are already distributed. + let err: ContractError = app + .execute_contract( + Addr::unchecked(BOB), + cw_vesting_addr, + &ExecuteMsg::Distribute { amount: None }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::InvalidWithdrawal { .. })); + + // Unvested funds have been returned to contract owner + assert_eq!( + get_balance_cw20(&app, "contract0", OWNER), + Uint128::new(INITIAL_BALANCE - TOTAL_VEST / 2) + ); + // Bob has gets the funds vest up until cancelation + assert_eq!( + get_balance_cw20(&app, "contract0", BOB), + Uint128::new(INITIAL_BALANCE + TOTAL_VEST / 2) + ); +} + +#[test] +fn test_catch_imposter_cw20() { + let mut app = setup_app(); + let (_, cw20_code_id, _) = setup_contracts(&mut app); + + let TestCase { + cw_vesting_addr, .. + } = setup_test_case(&mut app, InstantiateMsg::default(), &[]); + + // Create imposter cw20 + let cw20_imposter_addr = app + .instantiate_contract( + cw20_code_id, + Addr::unchecked(OWNER), + &cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(INITIAL_BALANCE), + }], + mint: None, + marketing: None, + }, + &[], + "cw20-base", + None, + ) + .unwrap(); + + let msg = Cw20ExecuteMsg::Send { + contract: cw_vesting_addr.to_string(), + amount: Uint128::new(TOTAL_VEST), + msg: to_binary(&ReceiveMsg::Fund {}).unwrap(), + }; + + // Errors that cw20 does not match what was expected + let error: ContractError = app + .execute_contract( + Addr::unchecked(OWNER), + Addr::unchecked(cw20_imposter_addr), + &msg, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(error, ContractError::WrongCw20); +} + +#[test] +fn test_incorrect_native_funding_amount() { + let mut app = setup_app(); + + let unchecked_denom = UncheckedDenom::Native(NATIVE_DENOM.to_string()); + + let msg = InstantiateMsg { + denom: unchecked_denom, + ..Default::default() + }; + + let alice = Addr::unchecked(ALICE); + + let (_, _, cw_vesting_code_id) = setup_contracts(&mut app); + + // Instantiate cw-vesting contract errors with incorrect amount + let error: ContractError = app + .instantiate_contract( + cw_vesting_code_id, + alice, + &msg, + &coins(100, NATIVE_DENOM), + "cw-vesting", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + error, + ContractError::WrongFundAmount { + sent: Uint128::new(100), + expected: Uint128::new(TOTAL_VEST) + } + ) +} + +/// should reject funding if the token is wrong, or the token amount is wrong. +#[test] +fn test_execution_rejection_recv() { + let env = mock_env; + let info = |sender| mock_info(sender, &[]); + let mut deps = mock_dependencies(); + + PAYMENT + .initialize( + deps.as_mut().storage, + VestInit { + total: Uint128::new(100), + schedule: Schedule::SaturatingLinear, + start_time: env().block.time, + duration_seconds: 60 * 60 * 24 * 7, + denom: CheckedDenom::Cw20(Addr::unchecked("cw20")), + recipient: Addr::unchecked("recipient"), + title: "title".to_string(), + description: Some("description".to_string()), + }, + ) + .unwrap(); + let mut deps = deps.as_mut(); + cw_ownable::initialize_owner(deps.storage, deps.api, Some("owner")).unwrap(); + + let err = execute_receive_cw20( + env(), + deps.branch(), + info("notcw20"), + Cw20ReceiveMsg { + sender: "random".to_string(), + amount: Uint128::new(100), + msg: to_binary(&ReceiveMsg::Fund {}).unwrap(), + }, + ) + .unwrap_err(); + assert_eq!(err, ContractError::WrongCw20); + + let err = execute_receive_cw20( + env(), + deps.branch(), + info("cw20"), + Cw20ReceiveMsg { + sender: "random".to_string(), + amount: Uint128::new(101), + msg: to_binary(&ReceiveMsg::Fund {}).unwrap(), + }, + ) + .unwrap_err(); + assert_eq!( + err, + ContractError::WrongFundAmount { + sent: Uint128::new(101), + expected: Uint128::new(100) + } + ); +} + +/// Should report zero distributable tokens when the contract is +/// unfunded. +#[test] +fn test_illiquid_when_unfunfed() { + let env = mock_env; + let mut deps = mock_dependencies(); + + PAYMENT + .initialize( + deps.as_mut().storage, + VestInit { + total: Uint128::new(100), + schedule: Schedule::SaturatingLinear, + start_time: env().block.time, + duration_seconds: 60 * 60 * 24 * 7, + denom: CheckedDenom::Cw20(Addr::unchecked("cw20")), + recipient: Addr::unchecked("recipient"), + title: "title".to_string(), + description: Some("description".to_string()), + }, + ) + .unwrap(); + let deps = deps.as_mut(); + cw_ownable::initialize_owner(deps.storage, deps.api, Some("owner")).unwrap(); + + // nothing is liquid in the unfunded state. + assert_eq!( + PAYMENT + .distributable( + deps.storage, + &PAYMENT.get_vest(deps.storage).unwrap(), + env().block.time + ) + .unwrap(), + Uint128::zero() + ); +} + +/// Ownership can not be renounced while the contract is canceled and +/// there are funds withdrawable by the owner as this would lock those +/// funds. +#[test] +fn test_update_owner() { + let env = mock_env; + let mut deps = mock_dependencies(); + PAYMENT + .initialize( + deps.as_mut().storage, + VestInit { + total: Uint128::new(100), + schedule: Schedule::SaturatingLinear, + start_time: env().block.time, + duration_seconds: 60 * 60 * 24 * 7, + denom: CheckedDenom::Cw20(Addr::unchecked("cw20")), + recipient: Addr::unchecked("recipient"), + title: "title".to_string(), + description: Some("description".to_string()), + }, + ) + .unwrap(); + let deps = deps.as_mut(); + cw_ownable::initialize_owner(deps.storage, deps.api, Some("owner")).unwrap(); + PAYMENT + .on_delegate( + deps.storage, + env().block.time, + "validator".to_string(), + Uint128::new(10), + ) + .unwrap(); + PAYMENT + .cancel(deps.storage, env().block.time, &Addr::unchecked("owner")) + .unwrap(); + let err = execute( + deps, + env(), + mock_info("owner", &[]), + ExecuteMsg::UpdateOwnership(Action::RenounceOwnership), + ) + .unwrap_err(); + assert_eq!(err, ContractError::Cancelled); +} + +#[test] +#[should_panic(expected = "can not vest a constant amount, specifiy two or more points")] +fn test_constant_piecewise_not_allowed() { + let mut app = setup_app(); + let instantiate = InstantiateMsg { + schedule: Schedule::PiecewiseLinear(vec![(1, Uint128::new(10))]), + ..Default::default() + }; + + setup_test_case(&mut app, instantiate, &[]); +} diff --git a/contracts/external/cw-vesting/src/vesting.rs b/contracts/external/cw-vesting/src/vesting.rs new file mode 100644 index 000000000..b0c786857 --- /dev/null +++ b/contracts/external/cw-vesting/src/vesting.rs @@ -0,0 +1,498 @@ +use std::cmp::min; + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ + Addr, Binary, CosmosMsg, DistributionMsg, StdResult, Storage, Timestamp, Uint128, Uint64, +}; +use cw_denom::CheckedDenom; +use cw_storage_plus::Item; +use wynd_utils::{Curve, PiecewiseLinear, SaturatingLinear}; + +use cw_stake_tracker::{StakeTracker, StakeTrackerQuery}; + +use crate::error::ContractError; + +pub struct Payment<'a> { + vesting: Item<'a, Vest>, + staking: StakeTracker<'a>, +} + +#[cw_serde] +pub struct Vest { + /// vested(t), where t is seconds since start_time. + vested: Curve, + start_time: Timestamp, + + pub status: Status, + pub recipient: Addr, + pub denom: CheckedDenom, + + /// The number of tokens that have been claimed by the vest receiver. + pub claimed: Uint128, + /// The number of tokens that have been slashed while staked by + /// the vest receiver. Slashed tokens count against the number of + /// tokens the receiver is entitled to. + pub slashed: Uint128, + + pub title: String, + pub description: Option, +} + +#[cw_serde] +pub enum Status { + Unfunded, + Funded, + Canceled { + /// owner_withdrawable(t). This is monotonically decreasing and + /// will be zero once the owner has completed withdrawing + /// their funds. + owner_withdrawable: Uint128, + }, +} + +#[cw_serde] +pub enum Schedule { + /// Vests linearally from `0` to `total`. + SaturatingLinear, + /// Vests by linearally interpolating between the provided + /// (seconds, amount) points. The first amount must be zero and + /// the last amount the total vesting amount. `seconds` are + /// seconds since the vest start time. + /// + /// There is a problem in the underlying Curve library that + /// doesn't allow zero start values, so the first value of + /// `seconds` must be > 1. To start at a particular time (if you + /// need that level of percision), subtract one from the true + /// start time, and make the first `seconds` value `1`. + /// + /// + PiecewiseLinear(Vec<(u64, Uint128)>), +} + +pub struct VestInit { + pub total: Uint128, + pub schedule: Schedule, + pub start_time: Timestamp, + pub duration_seconds: u64, + pub denom: CheckedDenom, + pub recipient: Addr, + pub title: String, + pub description: Option, +} + +impl<'a> Payment<'a> { + pub const fn new( + vesting_prefix: &'a str, + staked_prefix: &'a str, + validator_prefix: &'a str, + cardinality_prefix: &'a str, + ) -> Self { + Self { + vesting: Item::new(vesting_prefix), + staking: StakeTracker::new(staked_prefix, validator_prefix, cardinality_prefix), + } + } + + /// Validates its arguments and initializes the payment. Returns + /// the underlying vest. + pub fn initialize( + &self, + storage: &mut dyn Storage, + init: VestInit, + ) -> Result { + let v = Vest::new(init)?; + self.vesting.save(storage, &v)?; + Ok(v) + } + + pub fn get_vest(&self, storage: &dyn Storage) -> StdResult { + self.vesting.load(storage) + } + + /// calculates the number of liquid tokens avaliable. + fn liquid(&self, vesting: &Vest, staked: Uint128) -> Uint128 { + match vesting.status { + Status::Unfunded => Uint128::zero(), + Status::Funded => vesting.total() - vesting.claimed - staked - vesting.slashed, + Status::Canceled { owner_withdrawable } => { + // On cancelation, all liquid funds are settled and + // vesting.total() is set to the amount that has + // vested so far. Then, the remaining staked tokens + // are divided up between the owner and the vestee so + // that the vestee will receive all of their vested + // tokens. The following is then made true: + // + // staked = vesting_owed + owner_withdrawable + // staked = (vesting.total - vesting.claimed) + owner_withdrawable + // + // staked - currently_staked = claimable, as those tokens + // have unbonded and become avaliable and you can't + // delegate in the cancelled state, so: + // + // claimable = (vesting.total - vesting.claimed) + owner_withdrawable - currently_staked + // + // Note that this is slightly simplified, in practice we + // maintain: + // + // owner_withdrawable := owner.total - owner.claimed + // + // Where owner.total is the initial amount they were + // entitled to. + // + // ## Slashing + // + // If a slash occurs while the contract is in a + // canceled state, the slash amount is deducted from + // `owner_withdrawable`. We don't count slashes that + // occured during the Funded state as those are + // considered when computing `owner_withdrawable` + // initially. + owner_withdrawable + (vesting.total() - vesting.claimed) - staked + } + } + } + + /// Gets the current number tokens that may be distributed to the + /// vestee. + pub fn distributable( + &self, + storage: &dyn Storage, + vesting: &Vest, + t: Timestamp, + ) -> StdResult { + let staked = self.staking.total_staked(storage, t)?; + + let liquid = self.liquid(vesting, staked); + let claimable = (vesting.vested(t) - vesting.claimed).saturating_sub(vesting.slashed); + Ok(min(liquid, claimable)) + } + + /// Distributes vested tokens. If a specific amount is + /// requested, that amount will be distributed, otherwise all + /// tokens currently avaliable for distribution will be + /// transfered. + pub fn distribute( + &self, + storage: &mut dyn Storage, + t: Timestamp, + request: Option, + ) -> Result { + let vesting = self.vesting.load(storage)?; + + let distributable = self.distributable(storage, &vesting, t)?; + let request = request.unwrap_or(distributable); + + let mut vesting = vesting; + vesting.claimed += request; + self.vesting.save(storage, &vesting)?; + + if request > distributable || request.is_zero() { + Err(ContractError::InvalidWithdrawal { + request, + claimable: distributable, + }) + } else { + Ok(vesting + .denom + .get_transfer_to_message(&vesting.recipient, request)?) + } + } + + /// Cancels the vesting payment. The current amount vested becomes + /// the total amount that will ever vest, and all staked tokens + /// are unbonded. note that canceling does not impact already + /// vested tokens. + /// + /// Upon canceling, the contract will use any liquid tokens in the + /// contract to settle pending payments to the vestee, and then + /// return the rest to the owner. If there are not enough liquid + /// tokens to settle the vestee immediately, the vestee may + /// distribute tokens as normal until they have received the + /// amount of tokens they are entitled to. The owner may withdraw + /// the remaining tokens via the `withdraw_canceled` method. + pub fn cancel( + &self, + storage: &mut dyn Storage, + t: Timestamp, + owner: &Addr, + ) -> Result, ContractError> { + let mut vesting = self.vesting.load(storage)?; + if matches!(vesting.status, Status::Canceled { .. }) { + Err(ContractError::Cancelled {}) + } else { + let staked = self.staking.total_staked(storage, t)?; + + // Use liquid tokens to settle vestee as much as possible + // and return any remaining liquid funds to the owner. + let liquid = self.liquid(&vesting, staked); + let claimable = (vesting.vested(t) - vesting.claimed).saturating_sub(vesting.slashed); + let to_vestee = min(claimable, liquid); + let to_owner = liquid - to_vestee; + + vesting.claimed += to_vestee; + + // After cancelation liquid funds are settled, and + // the owners entitlement to the staked tokens is all + // staked tokens that are not needed to settle the + // vestee. + let owner_outstanding = + staked - (vesting.vested(t) - vesting.claimed).saturating_sub(vesting.slashed); + + vesting.cancel(t, owner_outstanding); + self.vesting.save(storage, &vesting)?; + + // As the vest is cancelled, the veste is no longer + // entitled to staking rewards that may accure before the + // owner has a chance to undelegate from validators. Set + // the owner to the reward receiver. + let mut msgs = vec![DistributionMsg::SetWithdrawAddress { + address: owner.to_string(), + } + .into()]; + + if !to_owner.is_zero() { + msgs.push(vesting.denom.get_transfer_to_message(owner, to_owner)?); + } + if !to_vestee.is_zero() { + msgs.push( + vesting + .denom + .get_transfer_to_message(&vesting.recipient, to_vestee)?, + ); + } + + Ok(msgs) + } + } + + pub fn withdraw_canceled_payment( + &self, + storage: &mut dyn Storage, + t: Timestamp, + request: Option, + owner: &Addr, + ) -> Result { + let vesting = self.vesting.load(storage)?; + let staked = self.staking.total_staked(storage, t)?; + if let Status::Canceled { owner_withdrawable } = vesting.status { + let liquid = self.liquid(&vesting, staked); + let claimable = min(liquid, owner_withdrawable); + let request = request.unwrap_or(claimable); + if request > claimable || request.is_zero() { + Err(ContractError::InvalidWithdrawal { request, claimable }) + } else { + let mut vesting = vesting; + vesting.status = Status::Canceled { + owner_withdrawable: owner_withdrawable - request, + }; + self.vesting.save(storage, &vesting)?; + + Ok(vesting.denom.get_transfer_to_message(owner, request)?) + } + } else { + Err(ContractError::NotCancelled) + } + } + + pub fn on_undelegate( + &self, + storage: &mut dyn Storage, + t: Timestamp, + validator: String, + amount: Uint128, + unbonding_duration_seconds: u64, + ) -> Result<(), ContractError> { + self.staking + .on_undelegate(storage, t, validator, amount, unbonding_duration_seconds)?; + Ok(()) + } + + pub fn on_redelegate( + &self, + storage: &mut dyn Storage, + t: Timestamp, + src: String, + dst: String, + amount: Uint128, + ) -> StdResult<()> { + self.staking.on_redelegate(storage, t, src, dst, amount)?; + Ok(()) + } + + pub fn on_delegate( + &self, + storage: &mut dyn Storage, + t: Timestamp, + validator: String, + amount: Uint128, + ) -> Result<(), ContractError> { + self.staking.on_delegate(storage, t, validator, amount)?; + Ok(()) + } + + pub fn set_funded(&self, storage: &mut dyn Storage) -> Result<(), ContractError> { + let mut v = self.vesting.load(storage)?; + debug_assert!(v.status == Status::Unfunded); + v.status = Status::Funded; + self.vesting.save(storage, &v)?; + Ok(()) + } + + /// Registers a slash of bonded tokens at time `t`. + /// + /// Invariants: + /// 1. The slash did indeed occur. + /// + /// Checking that these invariants are true is the responsibility + /// of the caller. + pub fn register_slash( + &self, + storage: &mut dyn Storage, + validator: String, + t: Timestamp, + amount: Uint128, + during_unbonding: bool, + ) -> Result<(), ContractError> { + if amount.is_zero() { + Err(ContractError::NoSlash) + } else { + let mut vest = self.vesting.load(storage)?; + match vest.status { + Status::Unfunded => return Err(ContractError::UnfundedSlash), + Status::Funded => vest.slashed += amount, + Status::Canceled { owner_withdrawable } => { + // if the owner withdraws, then registeres the + // slash as having happened (i.e. they are being + // mean), then the slash will be forced to spill + // over into the receivers claimable amount. + if amount > owner_withdrawable { + vest.status = Status::Canceled { + owner_withdrawable: Uint128::zero(), + }; + vest.slashed += amount - owner_withdrawable; + } else { + vest.status = Status::Canceled { + owner_withdrawable: owner_withdrawable - amount, + } + } + } + }; + self.vesting.save(storage, &vest)?; + if during_unbonding { + self.staking + .on_unbonding_slash(storage, t, validator, amount)?; + } else { + self.staking + .on_bonded_slash(storage, t, validator, amount)?; + } + Ok(()) + } + } + + /// Passes a query through to the vest's stake tracker which has + /// information about bonded and unbonding token balances. + pub fn query_stake(&self, storage: &dyn Storage, q: StakeTrackerQuery) -> StdResult { + self.staking.query(storage, q) + } + + /// Returns the duration of the vesting agreement (not the + /// remaining time) in seconds, or `None` if the vest has been cancelled. + pub fn duration(&self, storage: &dyn Storage) -> StdResult> { + self.vesting.load(storage).map(|v| v.duration()) + } +} + +impl Vest { + pub fn new(init: VestInit) -> Result { + if init.total.is_zero() { + Err(ContractError::ZeroVest) + } else if init.duration_seconds == 0 { + Err(ContractError::Instavest) + } else { + Ok(Self { + claimed: Uint128::zero(), + slashed: Uint128::zero(), + vested: init + .schedule + .into_curve(init.total, init.duration_seconds)?, + start_time: init.start_time, + denom: init.denom, + recipient: init.recipient, + status: Status::Unfunded, + title: init.title, + description: init.description, + }) + } + } + + /// Gets the total number of tokens that will vest as part of this + /// payment. + pub fn total(&self) -> Uint128 { + Uint128::new(self.vested.range().1) + } + + /// Gets the number of tokens that have vested at `time`. + pub fn vested(&self, t: Timestamp) -> Uint128 { + let elapsed = t.seconds().saturating_sub(self.start_time.seconds()); + self.vested.value(elapsed) + } + + /// Cancels the current vest. No additional tokens will vest after `t`. + pub fn cancel(&mut self, t: Timestamp, owner_withdrawable: Uint128) { + debug_assert!(!matches!(self.status, Status::Canceled { .. })); + + self.status = Status::Canceled { owner_withdrawable }; + self.vested = Curve::Constant { y: self.vested(t) }; + } + + /// Gets the duration of the vest. For constant curves, `None` is + /// returned. + pub fn duration(&self) -> Option { + let (start, end) = match &self.vested { + Curve::Constant { .. } => return None, + Curve::SaturatingLinear(SaturatingLinear { min_x, max_x, .. }) => (*min_x, *max_x), + Curve::PiecewiseLinear(PiecewiseLinear { steps }) => { + (steps[0].0, steps[steps.len() - 1].0) + } + }; + Some(Uint64::new(end - start)) + } +} + +impl Schedule { + /// The vesting schedule tracks vested(t), so for a curve to be + /// valid: + /// + /// 1. it must start at 0, + /// 2. it must end at total, + /// 3. it must never decrease. + /// + /// Piecewise curves must have at least two steps. One step would + /// be a constant vest (why would you want this?). + /// + /// A schedule is valid if `total` is zero: nothing will ever be + /// paid out. Consumers should consider validating that `total` is + /// non-zero. + pub fn into_curve(self, total: Uint128, duration_seconds: u64) -> Result { + let c = match self { + Schedule::SaturatingLinear => { + Curve::saturating_linear((0, 0), (duration_seconds, total.u128())) + } + Schedule::PiecewiseLinear(steps) => { + if steps.len() < 2 { + return Err(ContractError::ConstantVest); + } + Curve::PiecewiseLinear(wynd_utils::PiecewiseLinear { steps }) + } + }; + c.validate_monotonic_increasing()?; // => max >= curve(t) \forall t + let range = c.range(); + if range != (0, total.u128()) { + return Err(ContractError::VestRange { + min: Uint128::new(range.0), + max: Uint128::new(range.1), + }); + } + Ok(c) + } +} diff --git a/contracts/external/cw-vesting/src/vesting_tests.rs b/contracts/external/cw-vesting/src/vesting_tests.rs new file mode 100644 index 000000000..8e4825505 --- /dev/null +++ b/contracts/external/cw-vesting/src/vesting_tests.rs @@ -0,0 +1,367 @@ +use cosmwasm_std::{testing::mock_dependencies, Addr, Timestamp, Uint128}; +use cw_denom::CheckedDenom; +use wynd_utils::CurveError; + +use crate::{ + error::ContractError, + vesting::{Payment, Schedule, Status, Vest, VestInit}, +}; + +#[cfg(test)] +impl Default for VestInit { + fn default() -> Self { + VestInit { + total: Uint128::new(100_000_000), + schedule: Schedule::SaturatingLinear, + start_time: Timestamp::from_seconds(0), + duration_seconds: 100, + denom: CheckedDenom::Native("native".to_string()), + recipient: Addr::unchecked("recv"), + title: "title".to_string(), + description: Some("desc".to_string()), + } + } +} + +#[test] +fn test_distribute_funded() { + let storage = &mut mock_dependencies().storage; + let payment = Payment::new("vesting", "staked", "validator", "cardinality"); + + payment.initialize(storage, VestInit::default()).unwrap(); + payment.set_funded(storage).unwrap(); + + payment + .distribute(storage, Timestamp::default().plus_seconds(10), None) + .unwrap(); +} + +#[test] +fn test_distribute_nothing_to_claim() { + let storage = &mut mock_dependencies().storage; + let payment = Payment::new("vesting", "staked", "validator", "cardinality"); + + payment.initialize(storage, VestInit::default()).unwrap(); + + payment.set_funded(storage).unwrap(); + + // Can't distribute when there is nothing to claim. + let err = payment + .distribute(storage, Timestamp::default(), None) + .unwrap_err(); + assert_eq!( + err, + ContractError::InvalidWithdrawal { + request: Uint128::zero(), + claimable: Uint128::zero() + } + ); +} + +#[test] +fn test_distribute_half_way() { + let storage = &mut mock_dependencies().storage; + let payment = Payment::new("vesting", "staked", "validator", "cardinality"); + + payment.initialize(storage, VestInit::default()).unwrap(); + + payment.set_funded(storage).unwrap(); + // 50% of the way through, max claimable is 1/2 total. + let err = payment + .distribute( + storage, + Timestamp::from_seconds(50), + Some(Uint128::new(50_000_001)), + ) + .unwrap_err(); + assert_eq!( + err, + ContractError::InvalidWithdrawal { + request: Uint128::new(50_000_001), + claimable: Uint128::new(50_000_000) + } + ); +} + +#[test] +fn test_distribute() { + let storage = &mut mock_dependencies().storage; + let payment = Payment::new("vesting", "staked", "validator", "cardinality"); + + payment.initialize(storage, VestInit::default()).unwrap(); + + payment.set_funded(storage).unwrap(); + + // partially claiming increases claimed + let msg = payment + .distribute(storage, Timestamp::from_seconds(50), Some(Uint128::new(3))) + .unwrap(); + + assert_eq!( + msg, + payment + .get_vest(storage) + .unwrap() + .denom + .get_transfer_to_message(&Addr::unchecked("recv"), Uint128::new(3)) + .unwrap() + ); + assert_eq!(payment.get_vest(storage).unwrap().claimed, Uint128::new(3)); + + payment + .distribute( + storage, + Timestamp::from_seconds(50), + Some(Uint128::new(50_000_000 - 3)), + ) + .unwrap(); +} + +#[test] +fn test_vesting_validation() { + // Can not create vesting payment which vests zero tokens. + let init = VestInit { + total: Uint128::zero(), + ..Default::default() + }; + assert_eq!(Vest::new(init), Err(ContractError::ZeroVest {})); + + let init = VestInit { + schedule: Schedule::PiecewiseLinear(vec![ + (0, Uint128::zero()), + (1, Uint128::one()), + (2, Uint128::zero()), // non-monotonic-increasing + (3, Uint128::new(3)), + ]), + ..Default::default() + }; + + assert_eq!( + Vest::new(init), + Err(ContractError::Curve(CurveError::PointsOutOfOrder)) + ); + + // Doesn't reach total. + let init = VestInit { + schedule: Schedule::PiecewiseLinear(vec![ + (1, Uint128::zero()), + (2, Uint128::one()), + (5, Uint128::new(2)), + ]), + ..Default::default() + }; + + assert_eq!( + Vest::new(init), + Err(ContractError::VestRange { + min: Uint128::zero(), + max: Uint128::new(2) + }) + ); +} + +// owner and vestee. vestee has vested 50 tokens out of 100. 10 are +// claimed, 15 liquid, and 75 staked. owner then cancels the vest. +// +// the 15 liquid tokens should then all be sent to the vestee as the +// contract prioritises making them whole first. the vestee is now +// owed 25 tokens, and the owner 50. +// +// now the owner triggers an unbonding of 50 tokens. once they unbond, +// the vestee uses those tokens to make themselves whole. the owner +// may withdraw 25 tokens at this point, and later unbond the +// remaining 25 tokens and make themselves whole. +#[test] +fn test_complex_close() { + let storage = &mut mock_dependencies().storage; + let mut time = Timestamp::default(); + + let init = VestInit { + total: Uint128::new(100), + schedule: Schedule::SaturatingLinear, + start_time: time, + duration_seconds: 100, + denom: CheckedDenom::Native("ujuno".to_string()), + recipient: Addr::unchecked("recv"), + title: "t".to_string(), + description: Some("d".to_string()), + }; + let payment = Payment::new("vesting", "staked", "validator", "cardinality"); + + payment.initialize(storage, init).unwrap(); + payment.set_funded(storage).unwrap(); + + time = time.plus_seconds(50); + + payment + .distribute(storage, time, Some(Uint128::new(10))) + .unwrap(); + + payment + .on_delegate(storage, time, "v1".to_string(), Uint128::new(75)) + .unwrap(); + + let vest = payment.get_vest(storage).unwrap(); + assert_eq!(vest.claimed, Uint128::new(10)); + assert_eq!(vest.vested(time), Uint128::new(50)); + + payment + .cancel(storage, time, &Addr::unchecked("owner")) + .unwrap(); + + let vest = payment.get_vest(storage).unwrap(); + assert_eq!( + vest.status, + Status::Canceled { + owner_withdrawable: Uint128::new(50) + } + ); + assert_eq!(vest.vested(time) - vest.claimed, Uint128::new(25)); + + payment + .on_undelegate(storage, time, "v1".to_string(), Uint128::new(50), 25) + .unwrap(); + time = time.plus_seconds(25); + + payment.distribute(storage, time, None).unwrap(); + payment + .withdraw_canceled_payment(storage, time, None, &Addr::unchecked("owner")) + .unwrap(); + + let vest = payment.get_vest(storage).unwrap(); + assert_eq!(vest.claimed, Uint128::new(50)); + assert_eq!(vest.total(), Uint128::new(50)); + assert_eq!( + vest.status, + Status::Canceled { + owner_withdrawable: Uint128::new(25) + } + ); + + payment + .on_undelegate(storage, time, "v1".to_string(), Uint128::new(25), 25) + .unwrap(); + time = time.plus_seconds(25); + payment + .withdraw_canceled_payment(storage, time, None, &Addr::unchecked("owner")) + .unwrap(); + let vest = payment.get_vest(storage).unwrap(); + assert_eq!( + vest.status, + Status::Canceled { + owner_withdrawable: Uint128::zero() + } + ); +} + +#[test] +fn test_piecewise_linear() { + let storage = &mut mock_dependencies().storage; + let payment = Payment::new("vesting", "staked", "validator", "cardinality"); + + let vest = VestInit { + schedule: Schedule::PiecewiseLinear(vec![ + (1, Uint128::zero()), + (3, Uint128::new(4)), + (5, Uint128::new(8)), + ]), + total: Uint128::new(8), + ..Default::default() + }; + payment.initialize(storage, vest).unwrap(); + payment.set_funded(storage).unwrap(); + + let vesting = payment.get_vest(storage).unwrap(); + + // just check all the points as there aren't too many. + assert_eq!( + payment + .distributable(storage, &vesting, Timestamp::from_seconds(0)) + .unwrap(), + Uint128::zero() + ); + assert_eq!( + payment + .distributable(storage, &vesting, Timestamp::from_seconds(1)) + .unwrap(), + Uint128::zero() + ); + assert_eq!( + payment + .distributable(storage, &vesting, Timestamp::from_seconds(2)) + .unwrap(), + Uint128::new(2) + ); + assert_eq!( + payment + .distributable(storage, &vesting, Timestamp::from_seconds(3)) + .unwrap(), + Uint128::new(4) + ); + assert_eq!( + payment + .distributable(storage, &vesting, Timestamp::from_seconds(4)) + .unwrap(), + Uint128::new(6) + ); + assert_eq!( + payment + .distributable(storage, &vesting, Timestamp::from_seconds(5)) + .unwrap(), + Uint128::new(8) + ); + assert_eq!( + payment + .distributable(storage, &vesting, Timestamp::from_seconds(6)) + .unwrap(), + Uint128::new(8) + ); +} + +/// This test was contributed by Oak Security as part of issue 1 in +/// their audit report: "Undelegations will fail when redelegating to +/// a new validator". +#[test] +fn test_redelegate_should_increase_cardinality() { + let storage = &mut mock_dependencies().storage; + let time = Timestamp::default(); + + let init = VestInit { + total: Uint128::new(100), + schedule: Schedule::SaturatingLinear, + start_time: time, + duration_seconds: 100, + denom: CheckedDenom::Native("ujuno".to_string()), + recipient: Addr::unchecked("recv"), + title: "t".to_string(), + description: Some("d".to_string()), + }; + let payment = Payment::new("vesting", "staked", "validator", "cardinality"); + + payment.initialize(storage, init).unwrap(); + payment.set_funded(storage).unwrap(); + + let src = String::from("validator1"); + let dst = String::from("validator2"); + let amount = Uint128::new(10); + let ubs: u64 = 25; + + // delegate twice amount to validator 1 + payment + .on_delegate(storage, time, src.clone(), amount + amount) + .unwrap(); + // relegate to validator 2 + payment + .on_redelegate(storage, time, src.clone(), dst.clone(), amount) + .unwrap(); + // undelegate for validator 1 + payment + .on_undelegate(storage, time, src, amount, ubs) + .unwrap(); + // undelegate for validator 2 + payment + .on_undelegate(storage, time, dst, amount, ubs) + .unwrap(); // cardinality should have increased during + // undelegation so this should not cause an + // overflow when we remove stake. +} diff --git a/contracts/external/cw721-roles/.cargo/config b/contracts/external/cw721-roles/.cargo/config new file mode 100644 index 000000000..7d1a066c8 --- /dev/null +++ b/contracts/external/cw721-roles/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/external/cw721-roles/Cargo.toml b/contracts/external/cw721-roles/Cargo.toml new file mode 100644 index 000000000..1a6935974 --- /dev/null +++ b/contracts/external/cw721-roles/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "cw721-roles" +authors = ["Jake Hartnell"] +description = "Non-transferable CW721 NFT contract that incorporates voting weights and on-chain roles." +version = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-controllers = { workspace = true } +cw-ownable = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw4 = { workspace = true } +cw721 = { workspace = true } +cw721-base = { workspace = true, features = ["library"] } +dao-cw721-extensions = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +dao-testing = { workspace = true } +dao-voting-cw721-staked = { workspace = true } diff --git a/contracts/external/cw721-roles/README.md b/contracts/external/cw721-roles/README.md new file mode 100644 index 000000000..46c5f7079 --- /dev/null +++ b/contracts/external/cw721-roles/README.md @@ -0,0 +1,65 @@ +# cw721-roles + +This is a non-transferable NFT contract intended for use with DAOs. `cw721-roles` has an extension that allows for each NFT to have a `weight` associated with it, and also implements much of the functionality behind the [cw4-group contract](https://github.com/CosmWasm/cw-plus/tree/main/contracts/cw4-group) (credit to [Confio](https://confio.gmbh/) for their work on that). + +All methods of this contract are only callable via the configurable `minter` when the contract is created. It is primarily intended for use with DAOs. + +The `mint`, `burn`, `send`, and `transfer` methods have all been overriden from their default `cw721-base` versions, but work roughly the same with the caveat being they are only callable via the `minter`. All methods related to approvals are unsupported. + +## Extensions + +`cw721-roles` contains the following extensions: + +Token metadata has been extended with a weight and an optional human readable on-chain role which may be used in separate contracts for enforcing additional permissions. + +```rust +pub struct MetadataExt { + /// Optional on-chain role for this member, can be used by other contracts to enforce permissions + pub role: Option, + /// The voting weight of this role + pub weight: u64, +} +``` + +The contract has an additional execution extension that includes the ability to add and remove hooks for membership change events, as well as update a particular token's `token_uri`, `weight`, and `role`. All of these are only callable by the configured `minter`. + +```rust +pub enum ExecuteExt { + /// Add a new hook to be informed of all membership changes. + /// Must be called by Admin + AddHook { addr: String }, + /// Remove a hook. Must be called by Admin + RemoveHook { addr: String }, + /// Update the token_uri for a particular NFT. Must be called by minter / admin + UpdateTokenUri { + token_id: String, + token_uri: Option, + }, + /// Updates the voting weight of a token. Must be called by minter / admin + UpdateTokenWeight { token_id: String, weight: u64 }, + /// Udates the role of a token. Must be called by minter / admin + UpdateTokenRole { + token_id: String, + role: Option, + }, +} +``` + +The query extension implements queries that are compatible with the previously mentioned [cw4-group contract](https://github.com/CosmWasm/cw-plus/tree/main/contracts/cw4-group). + +```ignore +pub enum QueryExt { + /// Total weight at a given height + #[returns(cw4::TotalWeightResponse)] + TotalWeight { at_height: Option }, + /// Returns the weight of a certain member + #[returns(cw4::MemberResponse)] + Member { + addr: String, + at_height: Option, + }, + /// Shows all registered hooks. + #[returns(cw_controllers::HooksResponse)] + Hooks {}, +} +``` diff --git a/contracts/external/cw721-roles/examples/schema.rs b/contracts/external/cw721-roles/examples/schema.rs new file mode 100644 index 000000000..044f69e42 --- /dev/null +++ b/contracts/external/cw721-roles/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use cw721_roles::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/external/cw721-roles/schema/cw721-roles.json b/contracts/external/cw721-roles/schema/cw721-roles.json new file mode 100644 index 000000000..ddcfc3112 --- /dev/null +++ b/contracts/external/cw721-roles/schema/cw721-roles.json @@ -0,0 +1,2126 @@ +{ + "contract_name": "cw721-roles", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "minter", + "name", + "symbol" + ], + "properties": { + "minter": { + "description": "The minter is the only one who can create new NFTs. This is designed for a base NFT that is controlled by an external program or contract. You will likely replace this with custom logic in custom NFTs", + "type": "string" + }, + "name": { + "description": "Name of the NFT contract", + "type": "string" + }, + "symbol": { + "description": "Symbol of the NFT contract", + "type": "string" + } + }, + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "description": "This is like Cw721ExecuteMsg but we add a Mint command for an owner to make this stand-alone. You will likely want to remove mint and use other control logic in any contract that inherits this.", + "oneOf": [ + { + "description": "Transfer is a base message to move a token to another account without triggering actions", + "type": "object", + "required": [ + "transfer_nft" + ], + "properties": { + "transfer_nft": { + "type": "object", + "required": [ + "recipient", + "token_id" + ], + "properties": { + "recipient": { + "type": "string" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Send is a base message to transfer a token to a contract and trigger an action on the receiving contract.", + "type": "object", + "required": [ + "send_nft" + ], + "properties": { + "send_nft": { + "type": "object", + "required": [ + "contract", + "msg", + "token_id" + ], + "properties": { + "contract": { + "type": "string" + }, + "msg": { + "$ref": "#/definitions/Binary" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Allows operator to transfer / send the token from the owner's account. If expiration is set, then this allowance has a time/height limit", + "type": "object", + "required": [ + "approve" + ], + "properties": { + "approve": { + "type": "object", + "required": [ + "spender", + "token_id" + ], + "properties": { + "expires": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "spender": { + "type": "string" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove previously granted Approval", + "type": "object", + "required": [ + "revoke" + ], + "properties": { + "revoke": { + "type": "object", + "required": [ + "spender", + "token_id" + ], + "properties": { + "spender": { + "type": "string" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Allows operator to transfer / send any token from the owner's account. If expiration is set, then this allowance has a time/height limit", + "type": "object", + "required": [ + "approve_all" + ], + "properties": { + "approve_all": { + "type": "object", + "required": [ + "operator" + ], + "properties": { + "expires": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove previously granted ApproveAll permission", + "type": "object", + "required": [ + "revoke_all" + ], + "properties": { + "revoke_all": { + "type": "object", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Mint a new NFT, can only be called by the contract minter", + "type": "object", + "required": [ + "mint" + ], + "properties": { + "mint": { + "type": "object", + "required": [ + "extension", + "owner", + "token_id" + ], + "properties": { + "extension": { + "description": "Any custom extension used by this contract", + "allOf": [ + { + "$ref": "#/definitions/MetadataExt" + } + ] + }, + "owner": { + "description": "The owner of the newly minter NFT", + "type": "string" + }, + "token_id": { + "description": "Unique ID of the NFT", + "type": "string" + }, + "token_uri": { + "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Burn an NFT the sender has access to", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension msg", + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/ExecuteExt" + } + }, + "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" + ] + } + ] + }, + "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" + }, + "ExecuteExt": { + "oneOf": [ + { + "description": "Add a new hook to be informed of all membership changes. Must be called by Admin", + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Remove a hook. Must be called by Admin", + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update the token_uri for a particular NFT. Must be called by minter / admin", + "type": "object", + "required": [ + "update_token_uri" + ], + "properties": { + "update_token_uri": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "token_id": { + "type": "string" + }, + "token_uri": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the voting weight of a token. Must be called by minter / admin", + "type": "object", + "required": [ + "update_token_weight" + ], + "properties": { + "update_token_weight": { + "type": "object", + "required": [ + "token_id", + "weight" + ], + "properties": { + "token_id": { + "type": "string" + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Udates the role of a token. Must be called by minter / admin", + "type": "object", + "required": [ + "update_token_role" + ], + "properties": { + "update_token_role": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "role": { + "type": [ + "string", + "null" + ] + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "MetadataExt": { + "type": "object", + "required": [ + "weight" + ], + "properties": { + "role": { + "description": "Optional on-chain role for this member, can be used by other contracts to enforce permissions", + "type": [ + "string", + "null" + ] + }, + "weight": { + "description": "The voting weight of this role", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Return the owner of the given token, error if token does not exist", + "type": "object", + "required": [ + "owner_of" + ], + "properties": { + "owner_of": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired approvals, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Return operator that can access all of the owner's tokens.", + "type": "object", + "required": [ + "approval" + ], + "properties": { + "approval": { + "type": "object", + "required": [ + "spender", + "token_id" + ], + "properties": { + "include_expired": { + "type": [ + "boolean", + "null" + ] + }, + "spender": { + "type": "string" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Return approvals that a token has", + "type": "object", + "required": [ + "approvals" + ], + "properties": { + "approvals": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "include_expired": { + "type": [ + "boolean", + "null" + ] + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Return approval of a given operator for all tokens of an owner, error if not set", + "type": "object", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "object", + "required": [ + "operator", + "owner" + ], + "properties": { + "include_expired": { + "type": [ + "boolean", + "null" + ] + }, + "operator": { + "type": "string" + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "List all operators that can access all of the owner's tokens", + "type": "object", + "required": [ + "all_operators" + ], + "properties": { + "all_operators": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired items, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Total number of tokens issued", + "type": "object", + "required": [ + "num_tokens" + ], + "properties": { + "num_tokens": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns top-level metadata about the contract", + "type": "object", + "required": [ + "contract_info" + ], + "properties": { + "contract_info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns metadata about one particular token, based on *ERC721 Metadata JSON Schema* but directly from the contract", + "type": "object", + "required": [ + "nft_info" + ], + "properties": { + "nft_info": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns the result of both `NftInfo` and `OwnerOf` as one query as an optimization for clients", + "type": "object", + "required": [ + "all_nft_info" + ], + "properties": { + "all_nft_info": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired approvals, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "With Enumerable extension. Returns all tokens owned by the given address, [] if unset.", + "type": "object", + "required": [ + "tokens" + ], + "properties": { + "tokens": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "With Enumerable extension. Requires pagination. Lists all token_ids controlled by the contract.", + "type": "object", + "required": [ + "all_tokens" + ], + "properties": { + "all_tokens": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Return the minter", + "type": "object", + "required": [ + "minter" + ], + "properties": { + "minter": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension query", + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/QueryExt" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Query the contract's ownership information", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "QueryExt": { + "oneOf": [ + { + "description": "Total weight at a given height", + "type": "object", + "required": [ + "total_weight" + ], + "properties": { + "total_weight": { + "type": "object", + "properties": { + "at_height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a list of Members", + "type": "object", + "required": [ + "list_members" + ], + "properties": { + "list_members": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the weight of a certain member", + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + }, + "at_height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Shows all registered hooks.", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "migrate": null, + "sudo": null, + "responses": { + "all_nft_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllNftInfoResponse_for_QueryExt", + "type": "object", + "required": [ + "access", + "info" + ], + "properties": { + "access": { + "description": "Who can transfer the token", + "allOf": [ + { + "$ref": "#/definitions/OwnerOfResponse" + } + ] + }, + "info": { + "description": "Data on the token itself,", + "allOf": [ + { + "$ref": "#/definitions/NftInfoResponse_for_QueryExt" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "NftInfoResponse_for_QueryExt": { + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "description": "You can add any custom metadata here when you extend cw721-base", + "allOf": [ + { + "$ref": "#/definitions/QueryExt" + } + ] + }, + "token_uri": { + "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "OwnerOfResponse": { + "type": "object", + "required": [ + "approvals", + "owner" + ], + "properties": { + "approvals": { + "description": "If set this address is approved to transfer/send the token as well", + "type": "array", + "items": { + "$ref": "#/definitions/Approval" + } + }, + "owner": { + "description": "Owner of the token", + "type": "string" + } + }, + "additionalProperties": false + }, + "QueryExt": { + "oneOf": [ + { + "description": "Total weight at a given height", + "type": "object", + "required": [ + "total_weight" + ], + "properties": { + "total_weight": { + "type": "object", + "properties": { + "at_height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a list of Members", + "type": "object", + "required": [ + "list_members" + ], + "properties": { + "list_members": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the weight of a certain member", + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + }, + "at_height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Shows all registered hooks.", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "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" + } + } + }, + "all_operators": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OperatorsResponse", + "type": "object", + "required": [ + "operators" + ], + "properties": { + "operators": { + "type": "array", + "items": { + "$ref": "#/definitions/Approval" + } + } + }, + "additionalProperties": false, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "all_tokens": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TokensResponse", + "type": "object", + "required": [ + "tokens" + ], + "properties": { + "tokens": { + "description": "Contains all token_ids in lexicographical ordering If there are more than `limit`, use `start_after` in future queries to achieve pagination.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "approval": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ApprovalResponse", + "type": "object", + "required": [ + "approval" + ], + "properties": { + "approval": { + "$ref": "#/definitions/Approval" + } + }, + "additionalProperties": false, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "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" + } + } + }, + "approvals": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ApprovalsResponse", + "type": "object", + "required": [ + "approvals" + ], + "properties": { + "approvals": { + "type": "array", + "items": { + "$ref": "#/definitions/Approval" + } + } + }, + "additionalProperties": false, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "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" + } + } + }, + "contract_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ContractInfoResponse", + "type": "object", + "required": [ + "name", + "symbol" + ], + "properties": { + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + } + }, + "additionalProperties": false + }, + "extension": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Null", + "type": "null" + }, + "minter": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MinterResponse", + "description": "Shows who can mint these tokens", + "type": "object", + "properties": { + "minter": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "nft_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NftInfoResponse_for_QueryExt", + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "description": "You can add any custom metadata here when you extend cw721-base", + "allOf": [ + { + "$ref": "#/definitions/QueryExt" + } + ] + }, + "token_uri": { + "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "QueryExt": { + "oneOf": [ + { + "description": "Total weight at a given height", + "type": "object", + "required": [ + "total_weight" + ], + "properties": { + "total_weight": { + "type": "object", + "properties": { + "at_height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a list of Members", + "type": "object", + "required": [ + "list_members" + ], + "properties": { + "list_members": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the weight of a certain member", + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + }, + "at_height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Shows all registered hooks.", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "num_tokens": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NumTokensResponse", + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "operator": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OperatorResponse", + "type": "object", + "required": [ + "approval" + ], + "properties": { + "approval": { + "$ref": "#/definitions/Approval" + } + }, + "additionalProperties": false, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "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" + } + } + }, + "owner_of": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OwnerOfResponse", + "type": "object", + "required": [ + "approvals", + "owner" + ], + "properties": { + "approvals": { + "description": "If set this address is approved to transfer/send the token as well", + "type": "array", + "items": { + "$ref": "#/definitions/Approval" + } + }, + "owner": { + "description": "Owner of the token", + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "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" + } + } + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_String", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "type": [ + "string", + "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.", + "type": [ + "string", + "null" + ] + } + }, + "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 + } + ] + }, + "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" + } + } + }, + "tokens": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TokensResponse", + "type": "object", + "required": [ + "tokens" + ], + "properties": { + "tokens": { + "description": "Contains all token_ids in lexicographical ordering If there are more than `limit`, use `start_after` in future queries to achieve pagination.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/external/cw721-roles/src/contract.rs b/contracts/external/cw721-roles/src/contract.rs new file mode 100644 index 000000000..2fc13bc37 --- /dev/null +++ b/contracts/external/cw721-roles/src/contract.rs @@ -0,0 +1,493 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + from_binary, to_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Order, Response, + StdResult, SubMsg, Uint64, +}; +use cw4::{ + Member, MemberChangedHookMsg, MemberDiff, MemberListResponse, MemberResponse, + TotalWeightResponse, +}; +use cw721::{Cw721ReceiveMsg, NftInfoResponse, OwnerOfResponse}; +use cw721_base::{Cw721Contract, InstantiateMsg as Cw721BaseInstantiateMsg}; +use cw_storage_plus::Bound; +use cw_utils::maybe_addr; +use dao_cw721_extensions::roles::{ExecuteExt, MetadataExt, QueryExt}; +use std::cmp::Ordering; + +use crate::msg::{ExecuteMsg, QueryMsg}; +use crate::state::{MEMBERS, TOTAL}; +use crate::{error::RolesContractError as ContractError, state::HOOKS}; + +// Version info for migration +const CONTRACT_NAME: &str = "crates.io:cw721-roles"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// Settings for query pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +pub type Cw721Roles<'a> = Cw721Contract<'a, MetadataExt, Empty, ExecuteExt, QueryExt>; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: Cw721BaseInstantiateMsg, +) -> Result { + Cw721Roles::default().instantiate(deps.branch(), env.clone(), info, msg)?; + + // Initialize total weight to zero + TOTAL.save(deps.storage, &0, env.block.height)?; + + cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::default() + .add_attribute("contract_name", CONTRACT_NAME) + .add_attribute("contract_version", CONTRACT_VERSION)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + // Only owner / minter can execute + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + match msg { + ExecuteMsg::Mint { + token_id, + owner, + token_uri, + extension, + } => execute_mint(deps, env, info, token_id, owner, token_uri, extension), + ExecuteMsg::Burn { token_id } => execute_burn(deps, env, info, token_id), + ExecuteMsg::Extension { msg } => match msg { + ExecuteExt::AddHook { addr } => execute_add_hook(deps, info, addr), + ExecuteExt::RemoveHook { addr } => execute_remove_hook(deps, info, addr), + ExecuteExt::UpdateTokenRole { token_id, role } => { + execute_update_token_role(deps, env, info, token_id, role) + } + ExecuteExt::UpdateTokenUri { + token_id, + token_uri, + } => execute_update_token_uri(deps, env, info, token_id, token_uri), + ExecuteExt::UpdateTokenWeight { token_id, weight } => { + execute_update_token_weight(deps, env, info, token_id, weight) + } + }, + ExecuteMsg::TransferNft { + recipient, + token_id, + } => execute_transfer(deps, env, info, recipient, token_id), + ExecuteMsg::SendNft { + contract, + token_id, + msg, + } => execute_send(deps, env, info, token_id, contract, msg), + _ => Cw721Roles::default() + .execute(deps, env, info, msg) + .map_err(Into::into), + } +} + +pub fn execute_mint( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_id: String, + owner: String, + token_uri: Option, + extension: MetadataExt, +) -> Result { + let mut total = Uint64::from(TOTAL.load(deps.storage)?); + let mut diff = MemberDiff::new(owner.clone(), None, None); + + // Update member weights and total + MEMBERS.update( + deps.storage, + &deps.api.addr_validate(&owner)?, + env.block.height, + |old| -> StdResult<_> { + // Increment the total weight by the weight of the new token + total = total.checked_add(Uint64::from(extension.weight))?; + // Add the new NFT weight to the old weight for the owner + let new_weight = old.unwrap_or_default() + extension.weight; + // Set the diff for use in hooks + diff = MemberDiff::new(owner.clone(), old, Some(new_weight)); + Ok(new_weight) + }, + )?; + TOTAL.save(deps.storage, &total.u64(), env.block.height)?; + + let diffs = MemberChangedHookMsg { diffs: vec![diff] }; + + // Prepare hook messages + let msgs = HOOKS.prepare_hooks(deps.storage, |h| { + diffs.clone().into_cosmos_msg(h).map(SubMsg::new) + })?; + + // Call base mint + let res = Cw721Roles::default().execute( + deps, + env, + info, + ExecuteMsg::Mint { + token_id, + owner, + token_uri, + extension, + }, + )?; + + Ok(res.add_submessages(msgs)) +} + +pub fn execute_burn( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_id: String, +) -> Result { + // Lookup the owner of the NFT + let owner: OwnerOfResponse = from_binary(&Cw721Roles::default().query( + deps.as_ref(), + env.clone(), + QueryMsg::OwnerOf { + token_id: token_id.clone(), + include_expired: None, + }, + )?)?; + + // Get the weight of the token + let nft_info: NftInfoResponse = from_binary(&Cw721Roles::default().query( + deps.as_ref(), + env.clone(), + QueryMsg::NftInfo { + token_id: token_id.clone(), + }, + )?)?; + + let mut total = Uint64::from(TOTAL.load(deps.storage)?); + let mut diff = MemberDiff::new(owner.owner.clone(), None, None); + + // Update member weights and total + let owner_addr = deps.api.addr_validate(&owner.owner)?; + let old_weight = MEMBERS.load(deps.storage, &owner_addr)?; + + // Subtract the nft weight from the member's old weight + let new_weight = old_weight + .checked_sub(nft_info.extension.weight) + .ok_or(ContractError::CannotBurn {})?; + + // Subtract nft weight from the total + total = total.checked_sub(Uint64::from(nft_info.extension.weight))?; + + // Check if the new weight is now zero + if new_weight == 0 { + // New weight is now None + diff = MemberDiff::new(owner.owner, Some(old_weight), None); + // Remove owner from list of members + MEMBERS.remove(deps.storage, &owner_addr, env.block.height)?; + } else { + MEMBERS.update( + deps.storage, + &owner_addr, + env.block.height, + |old| -> StdResult<_> { + diff = MemberDiff::new(owner.owner.clone(), old, Some(new_weight)); + Ok(new_weight) + }, + )?; + } + + TOTAL.save(deps.storage, &total.u64(), env.block.height)?; + + let diffs = MemberChangedHookMsg { diffs: vec![diff] }; + + // Prepare hook messages + let msgs = HOOKS.prepare_hooks(deps.storage, |h| { + diffs.clone().into_cosmos_msg(h).map(SubMsg::new) + })?; + + // Remove the token + Cw721Roles::default() + .tokens + .remove(deps.storage, &token_id)?; + // Decrement the account + Cw721Roles::default().decrement_tokens(deps.storage)?; + + Ok(Response::new() + .add_attribute("action", "burn") + .add_attribute("sender", info.sender) + .add_attribute("token_id", token_id) + .add_submessages(msgs)) +} + +pub fn execute_transfer( + deps: DepsMut, + _env: Env, + info: MessageInfo, + recipient: String, + token_id: String, +) -> Result { + let contract = Cw721Roles::default(); + + let mut token = contract.tokens.load(deps.storage, &token_id)?; + // set owner and remove existing approvals + token.owner = deps.api.addr_validate(&recipient)?; + token.approvals = vec![]; + contract.tokens.save(deps.storage, &token_id, &token)?; + + Ok(Response::new() + .add_attribute("action", "transfer_nft") + .add_attribute("sender", info.sender) + .add_attribute("recipient", recipient) + .add_attribute("token_id", token_id)) +} + +pub fn execute_send( + deps: DepsMut, + _env: Env, + info: MessageInfo, + token_id: String, + recipient_contract: String, + msg: Binary, +) -> Result { + let contract = Cw721Roles::default(); + + let mut token = contract.tokens.load(deps.storage, &token_id)?; + // set owner and remove existing approvals + token.owner = deps.api.addr_validate(&recipient_contract)?; + token.approvals = vec![]; + contract.tokens.save(deps.storage, &token_id, &token)?; + + let send = Cw721ReceiveMsg { + sender: info.sender.to_string(), + token_id: token_id.clone(), + msg, + }; + + Ok(Response::new() + .add_message(send.into_cosmos_msg(recipient_contract.clone())?) + .add_attribute("action", "send_nft") + .add_attribute("sender", info.sender) + .add_attribute("recipient", recipient_contract) + .add_attribute("token_id", token_id)) +} + +pub fn execute_add_hook( + deps: DepsMut, + _info: MessageInfo, + addr: String, +) -> Result { + 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 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_token_role( + deps: DepsMut, + _env: Env, + info: MessageInfo, + token_id: String, + role: Option, +) -> Result { + let contract = Cw721Roles::default(); + + // Make sure NFT exists + let mut token = contract.tokens.load(deps.storage, &token_id)?; + + // Update role with new value + token.extension.role = role.clone(); + contract.tokens.save(deps.storage, &token_id, &token)?; + + Ok(Response::default() + .add_attribute("action", "update_token_role") + .add_attribute("sender", info.sender) + .add_attribute("token_id", token_id) + .add_attribute("role", role.unwrap_or_default())) +} + +pub fn execute_update_token_uri( + deps: DepsMut, + _env: Env, + info: MessageInfo, + token_id: String, + token_uri: Option, +) -> Result { + let contract = Cw721Roles::default(); + + let mut token = contract.tokens.load(deps.storage, &token_id)?; + + // Set new token URI + token.token_uri = token_uri.clone(); + contract.tokens.save(deps.storage, &token_id, &token)?; + + Ok(Response::new() + .add_attribute("action", "update_token_uri") + .add_attribute("sender", info.sender) + .add_attribute("token_id", token_id) + .add_attribute("token_uri", token_uri.unwrap_or_default())) +} + +pub fn execute_update_token_weight( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_id: String, + weight: u64, +) -> Result { + let contract = Cw721Roles::default(); + + // Make sure NFT exists + let mut token = contract.tokens.load(deps.storage, &token_id)?; + + let mut total = Uint64::from(TOTAL.load(deps.storage)?); + let mut diff = MemberDiff::new(token.clone().owner, None, None); + + // Update member weights and total + MEMBERS.update( + deps.storage, + &token.owner, + env.block.height, + |old| -> Result<_, ContractError> { + let new_total_weight; + let old_total_weight = old.unwrap_or_default(); + + // Check if new token weight is great than, less than, or equal to + // the old token weight + match weight.cmp(&token.extension.weight) { + Ordering::Greater => { + // Subtract the old token weight from the new token weight + let weight_difference = weight + .checked_sub(token.extension.weight) + .ok_or(ContractError::NegativeValue {})?; + + // Increment the total weight by the weight difference of the new token + total = total.checked_add(Uint64::from(weight_difference))?; + // Add the new NFT weight to the old weight for the owner + new_total_weight = old_total_weight + weight_difference; + // Set the diff for use in hooks + diff = MemberDiff::new(token.clone().owner, old, Some(new_total_weight)); + } + Ordering::Less => { + // Subtract the new token weight from the old token weight + let weight_difference = token + .extension + .weight + .checked_sub(weight) + .ok_or(ContractError::NegativeValue {})?; + + // Subtract the weight difference from the old total weight + new_total_weight = old_total_weight + .checked_sub(weight_difference) + .ok_or(ContractError::NegativeValue {})?; + + // Subtract difference from the total + total = total.checked_sub(Uint64::from(weight_difference))?; + } + Ordering::Equal => return Err(ContractError::NoWeightChange {}), + } + + Ok(new_total_weight) + }, + )?; + TOTAL.save(deps.storage, &total.u64(), env.block.height)?; + + let diffs = MemberChangedHookMsg { diffs: vec![diff] }; + + // Prepare hook messages + let msgs = HOOKS.prepare_hooks(deps.storage, |h| { + diffs.clone().into_cosmos_msg(h).map(SubMsg::new) + })?; + + // Save token weight + token.extension.weight = weight; + contract.tokens.save(deps.storage, &token_id, &token)?; + + Ok(Response::default() + .add_submessages(msgs) + .add_attribute("action", "update_token_weight") + .add_attribute("sender", info.sender) + .add_attribute("token_id", token_id) + .add_attribute("weight", weight.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Extension { msg } => match msg { + QueryExt::Hooks {} => to_binary(&HOOKS.query_hooks(deps)?), + QueryExt::ListMembers { start_after, limit } => { + to_binary(&query_list_members(deps, start_after, limit)?) + } + QueryExt::Member { addr, at_height } => { + to_binary(&query_member(deps, addr, at_height)?) + } + QueryExt::TotalWeight { at_height } => to_binary(&query_total_weight(deps, at_height)?), + }, + _ => Cw721Roles::default().query(deps, env, msg), + } +} + +pub fn query_total_weight(deps: Deps, height: Option) -> StdResult { + let weight = match height { + Some(h) => TOTAL.may_load_at_height(deps.storage, h), + None => TOTAL.may_load(deps.storage), + }? + .unwrap_or_default(); + Ok(TotalWeightResponse { weight }) +} + +pub fn query_member(deps: Deps, addr: String, height: Option) -> StdResult { + let addr = deps.api.addr_validate(&addr)?; + let weight = match height { + Some(h) => MEMBERS.may_load_at_height(deps.storage, &addr, h), + None => MEMBERS.may_load(deps.storage, &addr), + }?; + Ok(MemberResponse { weight }) +} + +pub fn query_list_members( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let addr = maybe_addr(deps.api, start_after)?; + let start = addr.as_ref().map(Bound::exclusive); + + let members = MEMBERS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + item.map(|(addr, weight)| Member { + addr: addr.into(), + weight, + }) + }) + .collect::>()?; + + Ok(MemberListResponse { members }) +} diff --git a/contracts/external/cw721-roles/src/error.rs b/contracts/external/cw721-roles/src/error.rs new file mode 100644 index 000000000..4d63e6efa --- /dev/null +++ b/contracts/external/cw721-roles/src/error.rs @@ -0,0 +1,29 @@ +use cosmwasm_std::{OverflowError, StdError}; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq)] +pub enum RolesContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Base(#[from] cw721_base::ContractError), + + #[error(transparent)] + HookError(#[from] cw_controllers::HookError), + + #[error("{0}")] + OverflowErr(#[from] OverflowError), + + #[error(transparent)] + Ownable(#[from] cw_ownable::OwnershipError), + + #[error("Cannot burn NFT, member weight would be negative")] + CannotBurn {}, + + #[error("Would result in negative value")] + NegativeValue {}, + + #[error("The submitted weight is equal to the previous value, no change will occur")] + NoWeightChange {}, +} diff --git a/contracts/external/cw721-roles/src/lib.rs b/contracts/external/cw721-roles/src/lib.rs new file mode 100644 index 000000000..fa8b1eadb --- /dev/null +++ b/contracts/external/cw721-roles/src/lib.rs @@ -0,0 +1,16 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::RolesContractError as ContractError; + +// So consumers don't need dependencies to interact with this contract. +pub use cw721_base::MinterResponse; +pub use cw_ownable::{Action, Ownership}; +pub use dao_cw721_extensions::roles::{ExecuteExt, MetadataExt, QueryExt}; diff --git a/contracts/external/cw721-roles/src/msg.rs b/contracts/external/cw721-roles/src/msg.rs new file mode 100644 index 000000000..fbfb15fd2 --- /dev/null +++ b/contracts/external/cw721-roles/src/msg.rs @@ -0,0 +1,5 @@ +use dao_cw721_extensions::roles::{ExecuteExt, MetadataExt, QueryExt}; + +pub type InstantiateMsg = cw721_base::InstantiateMsg; +pub type ExecuteMsg = cw721_base::ExecuteMsg; +pub type QueryMsg = cw721_base::QueryMsg; diff --git a/contracts/external/cw721-roles/src/state.rs b/contracts/external/cw721-roles/src/state.rs new file mode 100644 index 000000000..fa1a88570 --- /dev/null +++ b/contracts/external/cw721-roles/src/state.rs @@ -0,0 +1,22 @@ +use cosmwasm_std::Addr; +use cw_controllers::Hooks; +use cw_storage_plus::{SnapshotItem, SnapshotMap, Strategy}; + +// Hooks to contracts that will receive staking and unstaking messages. +pub const HOOKS: Hooks = Hooks::new("hooks"); + +/// A historic snapshot of total weight over time +pub const TOTAL: SnapshotItem = SnapshotItem::new( + "total", + "total__checkpoints", + "total__changelog", + Strategy::EveryBlock, +); + +/// A historic list of members and total voting weights +pub const MEMBERS: SnapshotMap<&Addr, u64> = SnapshotMap::new( + "members", + "members__checkpoints", + "members__changelog", + Strategy::EveryBlock, +); diff --git a/contracts/external/cw721-roles/src/tests.rs b/contracts/external/cw721-roles/src/tests.rs new file mode 100644 index 000000000..270a1bc0e --- /dev/null +++ b/contracts/external/cw721-roles/src/tests.rs @@ -0,0 +1,588 @@ +use cosmwasm_std::{to_binary, Addr, Binary}; +use cw4::{HooksResponse, Member, MemberListResponse, MemberResponse, TotalWeightResponse}; +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_voting_cw721_staked::msg::{InstantiateMsg as Cw721StakedInstantiateMsg, NftContract}; + +use crate::error::RolesContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +const ALICE: &str = "alice"; +const BOB: &str = "bob"; +const DAO: &str = "dao"; + +pub fn setup() -> (App, Addr) { + let mut app = App::default(); + + let cw721_id = app.store_code(cw721_roles_contract()); + let cw721_addr = app + .instantiate_contract( + cw721_id, + Addr::unchecked(DAO), + &InstantiateMsg { + name: "bad kids".to_string(), + symbol: "bad kids".to_string(), + minter: DAO.to_string(), + }, + &[], + "cw721_roles".to_string(), + None, + ) + .unwrap(); + + (app, cw721_addr) +} + +pub fn query_nft_owner( + app: &App, + nft: &Addr, + token_id: &str, +) -> Result { + let owner = app.wrap().query_wasm_smart( + nft, + &QueryMsg::OwnerOf { + token_id: token_id.to_string(), + include_expired: None, + }, + )?; + Ok(owner) +} + +pub fn query_member( + app: &App, + nft: &Addr, + member: &str, + at_height: Option, +) -> Result { + let member = app.wrap().query_wasm_smart( + nft, + &QueryMsg::Extension { + msg: QueryExt::Member { + addr: member.to_string(), + at_height, + }, + }, + )?; + Ok(member) +} + +pub fn query_total_weight( + app: &App, + nft: &Addr, + at_height: Option, +) -> Result { + let member = app.wrap().query_wasm_smart( + nft, + &QueryMsg::Extension { + msg: QueryExt::TotalWeight { at_height }, + }, + )?; + Ok(member) +} + +pub fn query_token_info( + app: &App, + nft: &Addr, + token_id: &str, +) -> Result, RolesContractError> { + let info = app.wrap().query_wasm_smart( + nft, + &QueryMsg::NftInfo { + token_id: token_id.to_string(), + }, + )?; + Ok(info) +} + +#[test] +fn test_minting_and_burning() { + let (mut app, cw721_addr) = setup(); + + // Mint token + let msg = ExecuteMsg::Mint { + token_id: "1".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Token was created successfully + let info: NftInfoResponse = query_token_info(&app, &cw721_addr, "1").unwrap(); + assert_eq!(info.extension.weight, 1); + + // Create another token for alice to give her even more total weight + let msg = ExecuteMsg::Mint { + token_id: "2".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Create a token for bob + let msg = ExecuteMsg::Mint { + token_id: "3".to_string(), + owner: BOB.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Query list of members + let members_list: MemberListResponse = app + .wrap() + .query_wasm_smart( + cw721_addr.clone(), + &QueryMsg::Extension { + msg: QueryExt::ListMembers { + start_after: None, + limit: None, + }, + }, + ) + .unwrap(); + assert_eq!( + members_list, + MemberListResponse { + members: vec![ + Member { + addr: ALICE.to_string(), + weight: 2 + }, + Member { + addr: BOB.to_string(), + weight: 1 + } + ] + } + ); + + // Member query returns total weight for alice + let member: MemberResponse = query_member(&app, &cw721_addr, ALICE, None).unwrap(); + assert_eq!(member.weight, Some(2)); + + // Total weight is now 3 + let total: TotalWeightResponse = query_total_weight(&app, &cw721_addr, None).unwrap(); + assert_eq!(total.weight, 3); + + // Burn a role for alice + let msg = ExecuteMsg::Burn { + token_id: "2".to_string(), + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Token is now gone + let res = query_token_info(&app, &cw721_addr, "2"); + assert!(res.is_err()); + + // Alice's weight has been update acordingly + let member: MemberResponse = query_member(&app, &cw721_addr, ALICE, None).unwrap(); + assert_eq!(member.weight, Some(1)); +} + +#[test] +fn test_minting_and_transfer_permissions() { + let (mut app, cw721_addr) = setup(); + + // Mint token + let msg = ExecuteMsg::Mint { + token_id: "1".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: Some("member".to_string()), + weight: 1, + }, + }; + + // Non-minter can't mint + app.execute_contract(Addr::unchecked(ALICE), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // DAO can mint successfully as the minter + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Non-minter can't transfer + let msg = ExecuteMsg::TransferNft { + recipient: BOB.to_string(), + token_id: "1".to_string(), + }; + app.execute_contract(Addr::unchecked(ALICE), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // DAO can transfer + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + let owner: OwnerOfResponse = query_nft_owner(&app, &cw721_addr, "1").unwrap(); + assert_eq!(owner.owner, BOB); +} + +#[test] +fn test_send_permissions() { + let (mut app, cw721_addr) = setup(); + + // Mint token + let msg = ExecuteMsg::Mint { + token_id: "1".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: Some("member".to_string()), + weight: 1, + }, + }; + // DAO can mint successfully as the minter + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Instantiate an NFT staking voting contract for testing SendNft + let dao_voting_cw721_staked_id = app.store_code(voting_cw721_staked_contract()); + let cw721_staked_addr = app + .instantiate_contract( + dao_voting_cw721_staked_id, + Addr::unchecked(DAO), + &Cw721StakedInstantiateMsg { + owner: None, + nft_contract: NftContract::Existing { + address: cw721_addr.to_string(), + }, + unstaking_duration: None, + active_threshold: None, + }, + &[], + "cw721-staking", + None, + ) + .unwrap(); + + // Non-minter can't send + let msg = ExecuteMsg::SendNft { + contract: cw721_staked_addr.to_string(), + token_id: "1".to_string(), + msg: to_binary(&Binary::default()).unwrap(), + }; + app.execute_contract(Addr::unchecked(ALICE), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // DAO can send + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Staking contract now owns the NFT + let owner: OwnerOfResponse = query_nft_owner(&app, &cw721_addr, "1").unwrap(); + assert_eq!(owner.owner, cw721_staked_addr.as_str()); +} + +#[test] +fn test_update_token_role() { + let (mut app, cw721_addr) = setup(); + + // Mint token + let msg = ExecuteMsg::Mint { + token_id: "1".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + let msg = ExecuteMsg::Extension { + msg: ExecuteExt::UpdateTokenRole { + token_id: "1".to_string(), + role: Some("queen".to_string()), + }, + }; + + // Only admin / minter can update role + app.execute_contract(Addr::unchecked(ALICE), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // Update token role + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Token was updated successfully + let info: NftInfoResponse = query_token_info(&app, &cw721_addr, "1").unwrap(); + assert_eq!(info.extension.role, Some("queen".to_string())); +} + +#[test] +fn test_update_token_uri() { + let (mut app, cw721_addr) = setup(); + + // Mint token + let msg = ExecuteMsg::Mint { + token_id: "1".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + let msg = ExecuteMsg::Extension { + msg: ExecuteExt::UpdateTokenUri { + token_id: "1".to_string(), + token_uri: Some("ipfs://abc...".to_string()), + }, + }; + + // Only admin / minter can update token_uri + app.execute_contract(Addr::unchecked(ALICE), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // Update token_uri + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Token was updated successfully + let info: NftInfoResponse = query_token_info(&app, &cw721_addr, "1").unwrap(); + assert_eq!(info.token_uri, Some("ipfs://abc...".to_string())); +} + +#[test] +fn test_update_token_weight() { + let (mut app, cw721_addr) = setup(); + + // Mint token + let msg = ExecuteMsg::Mint { + token_id: "1".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + let msg = ExecuteMsg::Extension { + msg: ExecuteExt::UpdateTokenWeight { + token_id: "1".to_string(), + weight: 2, + }, + }; + + // Only admin / minter can update token weight + app.execute_contract(Addr::unchecked(ALICE), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // Update token weight + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Token was updated successfully + let info: NftInfoResponse = query_token_info(&app, &cw721_addr, "1").unwrap(); + assert_eq!(info.extension.weight, 2); + + // New value should be reflected in member's voting weight + let member: MemberResponse = query_member(&app, &cw721_addr, ALICE, None).unwrap(); + assert_eq!(member.weight, Some(2)); + + // Update weight to a smaller value + app.execute_contract( + Addr::unchecked(DAO), + cw721_addr.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::UpdateTokenWeight { + token_id: "1".to_string(), + weight: 1, + }, + }, + &[], + ) + .unwrap(); + + // New value should be reflected in member's voting weight + let member: MemberResponse = query_member(&app, &cw721_addr, ALICE, None).unwrap(); + assert_eq!(member.weight, Some(1)); + + // Create another token for alice to give her even more total weight + let msg = ExecuteMsg::Mint { + token_id: "2".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 10, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Alice's weight should be updated to include both tokens + let member: MemberResponse = query_member(&app, &cw721_addr, ALICE, None).unwrap(); + assert_eq!(member.weight, Some(11)); + + // Update Alice's second token to 0 weight + // Update weight to a smaller value + app.execute_contract( + Addr::unchecked(DAO), + cw721_addr.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::UpdateTokenWeight { + token_id: "2".to_string(), + weight: 0, + }, + }, + &[], + ) + .unwrap(); + + // Alice's voting value should be 1 + let member: MemberResponse = query_member(&app, &cw721_addr, ALICE, None).unwrap(); + assert_eq!(member.weight, Some(1)); +} + +#[test] +fn test_zero_weight_token() { + let (mut app, cw721_addr) = setup(); + + // Mint token with zero weight + let msg = ExecuteMsg::Mint { + token_id: "1".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 0, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Token was created successfully + let info: NftInfoResponse = query_token_info(&app, &cw721_addr, "1").unwrap(); + assert_eq!(info.extension.weight, 0); + + // Member query returns total weight for alice + let member: MemberResponse = query_member(&app, &cw721_addr, ALICE, None).unwrap(); + assert_eq!(member.weight, Some(0)); +} + +#[test] +fn test_hooks() { + let (mut app, cw721_addr) = setup(); + + // Mint initial NFT + let msg = ExecuteMsg::Mint { + token_id: "1".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + let msg = ExecuteMsg::Extension { + msg: ExecuteExt::AddHook { + addr: DAO.to_string(), + }, + }; + + // Hook can't be added by non-minter + app.execute_contract(Addr::unchecked(ALICE), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // Hook can be added by the owner / minter + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Query hooks + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + cw721_addr.clone(), + &QueryMsg::Extension { + msg: QueryExt::Hooks {}, + }, + ) + .unwrap(); + assert_eq!( + hooks, + HooksResponse { + hooks: vec![DAO.to_string()] + } + ); + + // Test hook fires when a new member is added + let msg = ExecuteMsg::Mint { + token_id: "2".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }; + // Should error as the DAO is not a contract, meaning hooks fired + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // Should also error for burn, as this also fires hooks + let msg = ExecuteMsg::Burn { + token_id: "1".to_string(), + }; + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + let msg = ExecuteMsg::Extension { + msg: ExecuteExt::RemoveHook { + addr: DAO.to_string(), + }, + }; + + // Hook can't be removed by non-minter + app.execute_contract(Addr::unchecked(ALICE), cw721_addr.clone(), &msg, &[]) + .unwrap_err(); + + // Hook can be removed by the owner / minter + app.execute_contract(Addr::unchecked(DAO), cw721_addr.clone(), &msg, &[]) + .unwrap(); + + // Minting should now work again as there are no hooks to dead + app.execute_contract( + Addr::unchecked(DAO), + cw721_addr, + &ExecuteMsg::Mint { + token_id: "2".to_string(), + owner: ALICE.to_string(), + token_uri: Some("ipfs://xyz...".to_string()), + extension: MetadataExt { + role: None, + weight: 1, + }, + }, + &[], + ) + .unwrap(); +} diff --git a/contracts/external/dao-migrator/.cargo/config b/contracts/external/dao-migrator/.cargo/config new file mode 100644 index 000000000..ab407a024 --- /dev/null +++ b/contracts/external/dao-migrator/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/external/dao-migrator/Cargo.toml b/contracts/external/dao-migrator/Cargo.toml new file mode 100644 index 000000000..346ec7f6f --- /dev/null +++ b/contracts/external/dao-migrator/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "dao-migrator" +authors = ["Art3mix "] +description = "A DAO DAO migrator module for modules." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true, features = ["ibc3"] } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +thiserror = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +dao-interface = { workspace = true } + +dao-dao-core = { workspace = true, features = ["library"] } +dao-voting = { workspace = true } +dao-proposal-single = { workspace = true, features = ["library"] } +dao-voting-cw4 = { workspace = true, features = ["library"] } +cw20-stake = { workspace = true, features = ["library"] } +dao-voting-cw20-staked = { workspace = true, features = ["library"] } +cw20-base = { workspace = true, features = ["library"] } + +cw-utils-v1 = { workspace = true } +voting-v1 = { workspace = true } +cw-core-v1 = { workspace = true, features = ["library"] } +cw-proposal-single-v1 = { workspace = true, features = ["library"] } +cw20-staked-balance-voting-v1 = { workspace = true, features = ["library"] } +cw20-stake-v1 = { workspace = true, features = ["library"] } +cw-core-interface-v1 = { package = "cw-core-interface", version = "0.1.0", git = "https://github.com/DA0-DA0/dao-contracts.git", tag = "v1.0.0" } +cw4-voting-v1 = { package = "cw4-voting", version = "0.1.0", git = "https://github.com/DA0-DA0/dao-contracts.git", tag = "v1.0.0" } +cw20-v1 = { version = "0.13", package = "cw20" } +cw4-v1 = { version = "0.13", package = "cw4" } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } +cw-multi-test = { workspace = true } +dao-testing = { workspace = true } +anyhow = { workspace = true } diff --git a/contracts/external/dao-migrator/README.md b/contracts/external/dao-migrator/README.md new file mode 100644 index 000000000..944dc7bc0 --- /dev/null +++ b/contracts/external/dao-migrator/README.md @@ -0,0 +1,28 @@ +# dao-migrator + +Here is the [discussion](https://github.com/DA0-DA0/dao-contracts/discussions/607). + +A migrator module for a DAO DAO DAO which handles migration for DAO modules +and test it went successfully. + +DAO core migration is handled by a proposal, which adds this module and do +init callback to do migration on all regsitered modules. +If custom module is found, this TX fails and migration is cancelled, custom +module requires a custom migration to be done by the DAO. + +# General idea +1. Proposal is made to migrate DAO core to V2, which also adds this module to the DAO. +2. On init of this contract, a callback is fired to do the migration. +3. Then we check to make sure the DAO doesn't have custom modules. +4. We query the state before migration +5. We do the migration +6. We query the new state and test it to make sure everything is correct. +7. In any case where 1 migration fails, we fail the whole TX. + +# Important notes +* custom modules cannot reliably be migrated by this contract, +because of that we fail the process to avoid any unwanted results. + +* If any module migration fails we fail the whole thing, +this is to make sure that we either have a fully working V2, +or we do nothing and make sure the DAO is operational at any time. \ No newline at end of file diff --git a/contracts/external/dao-migrator/examples/schema.rs b/contracts/external/dao-migrator/examples/schema.rs new file mode 100644 index 000000000..9a8dfd2c1 --- /dev/null +++ b/contracts/external/dao-migrator/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_proposal_single::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/external/dao-migrator/schema/dao-migrator.json b/contracts/external/dao-migrator/schema/dao-migrator.json new file mode 100644 index 000000000..3e47c1302 --- /dev/null +++ b/contracts/external/dao-migrator/schema/dao-migrator.json @@ -0,0 +1,5921 @@ +{ + "contract_name": "dao-migrator", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "max_voting_period", + "only_members_execute", + "pre_propose_info", + "threshold" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "max_voting_period": { + "description": "The default maximum amount of time a proposal may be voted on before expiring.", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal.", + "type": "boolean" + }, + "pre_propose_info": { + "description": "Information about what addresses may create proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeInfo" + } + ] + }, + "threshold": { + "description": "The threshold a proposal must reach to complete.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "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 + } + ] + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$ref": "#/definitions/Uint128" + } + }, + "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": "Creates a proposal in the module.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "$ref": "#/definitions/SingleChoiceProposeMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Votes on a proposal. Voting power is determined by the DAO's voting power module.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to vote on.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "rationale": { + "description": "An optional rationale for why this vote was cast. This can be updated, set, or removed later by the address casting the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "The senders position on the proposal.", + "allOf": [ + { + "$ref": "#/definitions/Vote" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the sender's rationale for their vote on the specified proposal. Errors if no vote vote has been cast.", + "type": "object", + "required": [ + "update_rationale" + ], + "properties": { + "update_rationale": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "rationale": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Causes the messages associated with a passed proposal to be executed by the DAO.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to execute.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Closes a proposal that has failed (either not passed or timed out). If applicable this will cause the proposal deposit associated wth said proposal to be returned.", + "type": "object", + "required": [ + "close" + ], + "properties": { + "close": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to close.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the governance module's config.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "dao", + "max_voting_period", + "only_members_execute", + "threshold" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "dao": { + "description": "The address if tge DAO that this governance module is associated with.", + "type": "string" + }, + "max_voting_period": { + "description": "The default maximum amount of time a proposal may be voted on before expiring. This will only apply to proposals created after the config update.", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal. Applies to all outstanding and future proposals.", + "type": "boolean" + }, + "threshold": { + "description": "The new proposal passing threshold. This will only apply to proposals created after the config update.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update's the proposal creation policy used for this module. Only the DAO may call this method.", + "type": "object", + "required": [ + "update_pre_propose_info" + ], + "properties": { + "update_pre_propose_info": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/PreProposeInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds an address as a consumer of proposal hooks. Consumers of proposal hooks have hook messages executed on them whenever the status of a proposal changes or a proposal is created. If a consumer contract errors when handling a hook message it will be removed from the list of consumers.", + "type": "object", + "required": [ + "add_proposal_hook" + ], + "properties": { + "add_proposal_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a consumer of proposal hooks.", + "type": "object", + "required": [ + "remove_proposal_hook" + ], + "properties": { + "remove_proposal_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds an address as a consumer of vote hooks. Consumers of vote hooks have hook messages executed on them whenever the a vote is cast. If a consumer contract errors when handling a hook message it will be removed from the list of consumers.", + "type": "object", + "required": [ + "add_vote_hook" + ], + "properties": { + "add_vote_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removed a consumer of vote hooks.", + "type": "object", + "required": [ + "remove_vote_hook" + ], + "properties": { + "remove_vote_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "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 + } + ] + }, + "Empty": { + "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" + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "SingleChoiceProposeMsg": { + "description": "The contents of a message to create a proposal in the single choice proposal module.\n\nWe break this type out of `ExecuteMsg` because we want pre-propose modules that interact with this contract to be able to get type checking on their propose messages.\n\nWe move this type to this package so that pre-propose modules can import it without importing dao-proposal-single with the library feature which (as it is not additive) cause the execute exports to not be included in wasm builds.", + "type": "object", + "required": [ + "description", + "msgs", + "title" + ], + "properties": { + "description": { + "description": "A description of the proposal.", + "type": "string" + }, + "msgs": { + "description": "The messages that should be executed in response to this proposal passing.", + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "proposer": { + "description": "The address creating the proposal. If no pre-propose module is attached to this module this must always be None as the proposer is the sender of the propose message. If a pre-propose module is attached, this must be Some and will set the proposer of the proposal it creates.", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "The title of the proposal.", + "type": "string" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$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" + }, + "Vote": { + "oneOf": [ + { + "description": "Marks support for the proposal.", + "type": "string", + "enum": [ + "yes" + ] + }, + { + "description": "Marks opposition to the proposal.", + "type": "string", + "enum": [ + "no" + ] + }, + { + "description": "Marks participation but does not count towards the ratio of support / opposed.", + "type": "string", + "enum": [ + "abstain" + ] + } + ] + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Gets the proposal module's config.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets information about a proposal.", + "type": "object", + "required": [ + "proposal" + ], + "properties": { + "proposal": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all the proposals that have been cast in this module.", + "type": "object", + "required": [ + "list_proposals" + ], + "properties": { + "list_proposals": { + "type": "object", + "properties": { + "limit": { + "description": "The maximum number of proposals to return as part of this query. If no limit is set a max of 30 proposals will be returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "start_after": { + "description": "The proposal ID to start listing proposals after. For example, if this is set to 2 proposals with IDs 3 and higher will be returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the proposals that have been cast in this module in decending order of proposal ID.", + "type": "object", + "required": [ + "reverse_proposals" + ], + "properties": { + "reverse_proposals": { + "type": "object", + "properties": { + "limit": { + "description": "The maximum number of proposals to return as part of this query. If no limit is set a max of 30 proposals will be returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "start_before": { + "description": "The proposal ID to start listing proposals before. For example, if this is set to 6 proposals with IDs 5 and lower will be returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a voters position on a propsal.", + "type": "object", + "required": [ + "get_vote" + ], + "properties": { + "get_vote": { + "type": "object", + "required": [ + "proposal_id", + "voter" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "voter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the votes that have been cast on a proposal.", + "type": "object", + "required": [ + "list_votes" + ], + "properties": { + "list_votes": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "limit": { + "description": "The maximum number of votes to return in response to this query. If no limit is specified a max of 30 are returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "proposal_id": { + "description": "The proposal to list the votes of.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "start_after": { + "description": "The voter to start listing votes after. Ordering is done alphabetically.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the number of proposals that have been created in this module.", + "type": "object", + "required": [ + "proposal_count" + ], + "properties": { + "proposal_count": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the current proposal creation policy for this module.", + "type": "object", + "required": [ + "proposal_creation_policy" + ], + "properties": { + "proposal_creation_policy": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the consumers of proposal hooks for this module.", + "type": "object", + "required": [ + "proposal_hooks" + ], + "properties": { + "proposal_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the consumers of vote hooks for this module.", + "type": "object", + "required": [ + "vote_hooks" + ], + "properties": { + "vote_hooks": { + "type": "object", + "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 + }, + { + "description": "Returns the proposal ID that will be assigned to the next proposal created.", + "type": "object", + "required": [ + "next_proposal_id" + ], + "properties": { + "next_proposal_id": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "from_v1" + ], + "properties": { + "from_v1": { + "type": "object", + "required": [ + "close_proposal_on_execution_failure", + "pre_propose_info" + ], + "properties": { + "close_proposal_on_execution_failure": { + "description": "This field was not present in DAO DAO v1. To migrate, a value must be specified.\n\nIf set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "pre_propose_info": { + "description": "This field was not present in DAO DAO v1. To migrate, a value must be specified.\n\nThis contains information about how a pre-propose module may be configured. If set to \"AnyoneMayPropose\", there will be no pre-propose module and consequently, no deposit or membership checks when submitting a proposal. The \"ModuleMayPropose\" option allows for instantiating a prepropose module which will handle deposit verification and return logic.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeInfo" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "from_compatible" + ], + "properties": { + "from_compatible": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "The governance module's configuration.", + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "dao", + "max_voting_period", + "only_members_execute", + "threshold" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "dao": { + "description": "The address of the DAO that this governance module is associated with.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "max_voting_period": { + "description": "The default maximum amount of time a proposal may be voted on before expiring.", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal.", + "type": "boolean" + }, + "threshold": { + "description": "The threshold a proposal must reach to complete.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + } + }, + "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" + }, + "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 + } + ] + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$ref": "#/definitions/Uint128" + } + }, + "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" + } + } + }, + "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" + }, + "get_vote": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoteResponse", + "description": "Information about a vote.", + "type": "object", + "properties": { + "vote": { + "description": "None if no such vote, Some otherwise.", + "anyOf": [ + { + "$ref": "#/definitions/VoteInfo" + }, + { + "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" + }, + "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" + }, + "Vote": { + "oneOf": [ + { + "description": "Marks support for the proposal.", + "type": "string", + "enum": [ + "yes" + ] + }, + { + "description": "Marks opposition to the proposal.", + "type": "string", + "enum": [ + "no" + ] + }, + { + "description": "Marks participation but does not count towards the ratio of support / opposed.", + "type": "string", + "enum": [ + "abstain" + ] + } + ] + }, + "VoteInfo": { + "description": "Information about a vote that was cast.", + "type": "object", + "required": [ + "power", + "vote", + "voter" + ], + "properties": { + "power": { + "description": "The voting power behind the vote.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "rationale": { + "description": "Address-specified rationale for the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "Position on the vote.", + "allOf": [ + { + "$ref": "#/definitions/Vote" + } + ] + }, + "voter": { + "description": "The address that voted.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "additionalProperties": false, + "definitions": { + "ContractVersion": { + "type": "object", + "required": [ + "contract", + "version" + ], + "properties": { + "contract": { + "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", + "type": "string" + }, + "version": { + "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_proposals": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalListResponse", + "description": "A list of proposals returned by `ListProposals` and `ReverseProposals`.", + "type": "object", + "required": [ + "proposals" + ], + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/definitions/ProposalResponse" + } + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "ProposalResponse": { + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "description": "The ID of the proposal being returned.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/SingleChoiceProposal" + } + }, + "additionalProperties": false + }, + "SingleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "description", + "expiration", + "msgs", + "proposer", + "start_height", + "status", + "threshold", + "title", + "total_power", + "votes" + ], + "properties": { + "allow_revoting": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "msgs": { + "description": "The messages that will be executed should this proposal pass.", + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "$ref": "#/definitions/Status" + }, + "threshold": { + "description": "The threshold at which this proposal will pass.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total amount of voting power at the time of this proposal's creation.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "$ref": "#/definitions/Votes" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "Votes": { + "type": "object", + "required": [ + "abstain", + "no", + "yes" + ], + "properties": { + "abstain": { + "$ref": "#/definitions/Uint128" + }, + "no": { + "$ref": "#/definitions/Uint128" + }, + "yes": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "list_votes": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoteListResponse", + "description": "Information about the votes for a proposal.", + "type": "object", + "required": [ + "votes" + ], + "properties": { + "votes": { + "type": "array", + "items": { + "$ref": "#/definitions/VoteInfo" + } + } + }, + "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" + }, + "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" + }, + "Vote": { + "oneOf": [ + { + "description": "Marks support for the proposal.", + "type": "string", + "enum": [ + "yes" + ] + }, + { + "description": "Marks opposition to the proposal.", + "type": "string", + "enum": [ + "no" + ] + }, + { + "description": "Marks participation but does not count towards the ratio of support / opposed.", + "type": "string", + "enum": [ + "abstain" + ] + } + ] + }, + "VoteInfo": { + "description": "Information about a vote that was cast.", + "type": "object", + "required": [ + "power", + "vote", + "voter" + ], + "properties": { + "power": { + "description": "The voting power behind the vote.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "rationale": { + "description": "Address-specified rationale for the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "Position on the vote.", + "allOf": [ + { + "$ref": "#/definitions/Vote" + } + ] + }, + "voter": { + "description": "The address that voted.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "next_proposal_id": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalResponse", + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "description": "The ID of the proposal being returned.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/SingleChoiceProposal" + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "SingleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "description", + "expiration", + "msgs", + "proposer", + "start_height", + "status", + "threshold", + "title", + "total_power", + "votes" + ], + "properties": { + "allow_revoting": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "msgs": { + "description": "The messages that will be executed should this proposal pass.", + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "$ref": "#/definitions/Status" + }, + "threshold": { + "description": "The threshold at which this proposal will pass.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total amount of voting power at the time of this proposal's creation.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "$ref": "#/definitions/Votes" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "Votes": { + "type": "object", + "required": [ + "abstain", + "no", + "yes" + ], + "properties": { + "abstain": { + "$ref": "#/definitions/Uint128" + }, + "no": { + "$ref": "#/definitions/Uint128" + }, + "yes": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "proposal_count": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal_creation_policy": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalCreationPolicy", + "oneOf": [ + { + "description": "Anyone may create a proposal, free of charge.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only ADDR may create proposals. It is expected that ADDR is a pre-propose module, though we only require that it is a valid address.", + "type": "object", + "required": [ + "module" + ], + "properties": { + "module": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + }, + "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" + } + } + }, + "proposal_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "reverse_proposals": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalListResponse", + "description": "A list of proposals returned by `ListProposals` and `ReverseProposals`.", + "type": "object", + "required": [ + "proposals" + ], + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/definitions/ProposalResponse" + } + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "ProposalResponse": { + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "description": "The ID of the proposal being returned.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/SingleChoiceProposal" + } + }, + "additionalProperties": false + }, + "SingleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "description", + "expiration", + "msgs", + "proposer", + "start_height", + "status", + "threshold", + "title", + "total_power", + "votes" + ], + "properties": { + "allow_revoting": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "msgs": { + "description": "The messages that will be executed should this proposal pass.", + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "$ref": "#/definitions/Status" + }, + "threshold": { + "description": "The threshold at which this proposal will pass.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total amount of voting power at the time of this proposal's creation.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "$ref": "#/definitions/Votes" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "Votes": { + "type": "object", + "required": [ + "abstain", + "no", + "yes" + ], + "properties": { + "abstain": { + "$ref": "#/definitions/Uint128" + }, + "no": { + "$ref": "#/definitions/Uint128" + }, + "yes": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "vote_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/external/dao-migrator/src/contract.rs b/contracts/external/dao-migrator/src/contract.rs new file mode 100644 index 000000000..ae91152d5 --- /dev/null +++ b/contracts/external/dao-migrator/src/contract.rs @@ -0,0 +1,428 @@ +use std::{collections::HashSet, env}; + +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Env, MessageInfo, Reply, Response, + StdResult, SubMsg, WasmMsg, +}; +use cw2::set_contract_version; +use dao_interface::{ + query::SubDao, + state::{ModuleInstantiateCallback, ProposalModule}, +}; + +use crate::{ + error::ContractError, + msg::{ExecuteMsg, InstantiateMsg, MigrateV1ToV2, QueryMsg}, + state::{CORE_ADDR, MODULES_ADDRS, TEST_STATE}, + types::{ + CodeIdPair, MigrationMsgs, MigrationParams, ModulesAddrs, TestState, V1CodeIds, V2CodeIds, + }, + utils::state_queries::{ + query_proposal_count_v1, query_proposal_count_v2, query_proposal_v1, query_proposal_v2, + query_single_voting_power_v1, query_single_voting_power_v2, query_total_voting_power_v1, + query_total_voting_power_v2, + }, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-migrator"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub(crate) const V1_V2_REPLY_ID: u64 = 1; + +#[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)?; + CORE_ADDR.save(deps.storage, &info.sender)?; + + Ok( + Response::default().set_data(to_binary(&ModuleInstantiateCallback { + msgs: vec![WasmMsg::Execute { + contract_addr: env.contract.address.to_string(), + msg: to_binary(&MigrateV1ToV2 { + sub_daos: msg.sub_daos, + migration_params: msg.migration_params, + v1_code_ids: msg.v1_code_ids, + v2_code_ids: msg.v2_code_ids, + })?, + funds: vec![], + } + .into()], + })?), + ) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + execute_migration_v1_v2( + deps, + env, + info, + msg.sub_daos, + msg.migration_params, + msg.v1_code_ids, + msg.v2_code_ids, + ) +} + +fn execute_migration_v1_v2( + deps: DepsMut, + env: Env, + info: MessageInfo, + sub_daos: Vec, + migration_params: MigrationParams, + v1_code_ids: V1CodeIds, + v2_code_ids: V2CodeIds, +) -> Result { + if info.sender != CORE_ADDR.load(deps.storage)? { + return Err(ContractError::Unauthorized {}); + } + + //Check if params doesn't have duplicates + let mut uniq = HashSet::new(); + if !migration_params + .proposal_params + .iter() + .all(|(addr, _)| uniq.insert(addr)) + { + return Err(ContractError::DuplicateProposalParams); + } + + // List of code ids pairs we got and the migration msg of each one of them. + let proposal_pairs: Vec<(String, CodeIdPair)> = migration_params + .proposal_params + .clone() + .into_iter() + .map(|(addr, proposal_params)| { + ( + addr, + CodeIdPair::new( + v1_code_ids.proposal_single, + v2_code_ids.proposal_single, + MigrationMsgs::DaoProposalSingle( + dao_proposal_single::msg::MigrateMsg::FromV1 { + close_proposal_on_execution_failure: proposal_params + .close_proposal_on_execution_failure, + pre_propose_info: proposal_params.pre_propose_info, + }, + ), + ), + ) + }) + .collect(); // cw-proposal-single -> dao_proposal_single + let voting_pairs: Vec = vec![ + CodeIdPair::new( + v1_code_ids.cw4_voting, + v2_code_ids.cw4_voting, + MigrationMsgs::DaoVotingCw4(dao_voting_cw4::msg::MigrateMsg {}), + ), // cw4-voting -> dao_voting_cw4 + CodeIdPair::new( + v1_code_ids.cw20_staked_balances_voting, + v2_code_ids.cw20_staked_balances_voting, + MigrationMsgs::DaoVotingCw20Staked(dao_voting_cw20_staked::msg::MigrateMsg {}), + ), // cw20-staked-balances-voting -> dao-voting-cw20-staked + ]; + let staking_pair = CodeIdPair::new( + v1_code_ids.cw20_stake, + v2_code_ids.cw20_stake, + MigrationMsgs::Cw20Stake(cw20_stake::msg::MigrateMsg::FromV1 {}), + ); // cw20-stake -> cw20_stake + + let mut msgs: Vec = vec![]; + let mut modules_addrs = ModulesAddrs::default(); + + // -------------------- + // verify voting module + // -------------------- + let voting_module: Addr = deps.querier.query_wasm_smart( + info.sender.clone(), + &dao_interface::msg::QueryMsg::VotingModule {}, + )?; + + let voting_code_id = + if let Ok(contract_info) = deps.querier.query_wasm_contract_info(voting_module.clone()) { + contract_info.code_id + } else { + // Return false if we don't get contract info, means something went wrong. + return Err(ContractError::NoContractInfo { + address: voting_module.into(), + }); + }; + + if let Some(voting_pair) = voting_pairs + .into_iter() + .find(|x| x.v1_code_id == voting_code_id) + { + msgs.push( + WasmMsg::Migrate { + contract_addr: voting_module.to_string(), + new_code_id: voting_pair.v2_code_id, + msg: to_binary(&voting_pair.migrate_msg).unwrap(), + } + .into(), + ); + modules_addrs.voting = Some(voting_module.clone()); + + // If voting module is staked cw20, we check that they confirmed migration + // and migrate the cw20_staked module + if let MigrationMsgs::DaoVotingCw20Staked(_) = voting_pair.migrate_msg { + if !migration_params + .migrate_stake_cw20_manager + .unwrap_or_default() + { + return Err(ContractError::DontMigrateCw20); + } + + let cw20_staked_addr: Addr = deps.querier.query_wasm_smart( + voting_module, + &cw20_staked_balance_voting_v1::msg::QueryMsg::StakingContract {}, + )?; + + let c20_staked_code_id = if let Ok(contract_info) = deps + .querier + .query_wasm_contract_info(cw20_staked_addr.clone()) + { + contract_info.code_id + } else { + // Return false if we don't get contract info, means something went wrong. + return Err(ContractError::NoContractInfo { + address: cw20_staked_addr.into(), + }); + }; + + // If module is not DAO DAO module + if c20_staked_code_id != staking_pair.v1_code_id { + return Err(ContractError::CantMigrateModule { + code_id: c20_staked_code_id, + }); + } + + msgs.push( + WasmMsg::Migrate { + contract_addr: cw20_staked_addr.to_string(), + new_code_id: staking_pair.v2_code_id, + msg: to_binary(&staking_pair.migrate_msg).unwrap(), + } + .into(), + ); + } + } else { + return Err(ContractError::VotingModuleNotFound); + } + + // ----------------------- + // verify proposal modules + // ----------------------- + // We take all the proposal modules of the DAO. + let proposal_modules: Vec = deps.querier.query_wasm_smart( + info.sender.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + )?; + + // We remove 1 because migration module is a proposal module, and we skip it. + if proposal_modules.len() - 1 != (proposal_pairs.len()) { + return Err(ContractError::MigrationParamsNotEqualProposalModulesLength); + } + + // Loop over proposals and verify that they are valid DAO DAO modules + // and set them to be migrated. + proposal_modules + .iter() + .try_for_each(|module| -> Result<(), ContractError> { + // Instead of doing 2 loops, just ignore our module, we don't care about the vec after this. + if module.address == env.contract.address { + return Ok(()); + } + + let proposal_pair = proposal_pairs + .iter() + .find(|(addr, _)| addr == module.address.as_str()) + .ok_or(ContractError::ProposalModuleNotFoundInParams { + addr: module.address.clone().into(), + })? + .1 + .clone(); + + // Get the code id of the module + let proposal_code_id = if let Ok(contract_info) = deps + .querier + .query_wasm_contract_info(module.address.clone()) + { + Ok(contract_info.code_id) + } else { + // Return false if we don't get contract info, means something went wrong. + Err(ContractError::NoContractInfo { + address: module.address.clone().into(), + }) + }?; + + // check if Code id is valid DAO DAO code id + if proposal_code_id == proposal_pair.v1_code_id { + msgs.push( + WasmMsg::Migrate { + contract_addr: module.address.to_string(), + new_code_id: proposal_pair.v2_code_id, + msg: to_binary(&proposal_pair.migrate_msg).unwrap(), + } + .into(), + ); + modules_addrs.proposals.push(module.address.clone()); + Ok(()) + } else { + // Return false because we couldn't find the code id on our list. + Err(ContractError::CantMigrateModule { + code_id: proposal_code_id, + }) + }?; + + Ok(()) + })?; + + // We successfully verified all modules of the DAO, we can send migration msgs. + + // Verify we got voting address, and at least 1 proposal single address + modules_addrs.verify()?; + MODULES_ADDRS.save(deps.storage, &modules_addrs)?; + // Do the state query, and save it in storage + let state = query_state_v1(deps.as_ref(), modules_addrs)?; + TEST_STATE.save(deps.storage, &state)?; + + // Add sub daos to core + msgs.push( + WasmMsg::Execute { + contract_addr: info.sender.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::UpdateSubDaos { + to_add: sub_daos, + to_remove: vec![], + })?, + funds: vec![], + } + .into(), + ); + + // Create the ExecuteProposalHook msg. + let proposal_hook_msg = SubMsg::reply_on_success( + WasmMsg::Execute { + contract_addr: info.sender.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::ExecuteProposalHook { msgs })?, + funds: vec![], + }, + V1_V2_REPLY_ID, + ); + + Ok(Response::default().add_submessage(proposal_hook_msg)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(_deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg {} +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, env: Env, reply: Reply) -> Result { + match reply.id { + V1_V2_REPLY_ID => { + let core_addr = CORE_ADDR.load(deps.storage)?; + // This is called after we got all the migrations successfully + test_state(deps.as_ref())?; + + // FINALLY remove the migrator from the core + // Reason we do it now, is because we first need to test the state + // and only then delete our module if everything worked out. + let remove_msg = WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::ExecuteProposalHook { + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::UpdateProposalModules { + to_add: vec![], + to_disable: vec![env.contract.address.to_string()], + })?, + funds: vec![], + } + .into()], + })?, + funds: vec![], + }; + + Ok(Response::default() + .add_message(remove_msg) + .add_attribute("action", "migrate") + .add_attribute("status", "success")) + } + _ => Err(ContractError::UnrecognisedReplyId), + } +} + +fn query_state_v1(deps: Deps, module_addrs: ModulesAddrs) -> Result { + let proposal_counts = query_proposal_count_v1(deps, module_addrs.proposals.clone())?; + let (proposals, sample_proposal_data) = query_proposal_v1(deps, module_addrs.proposals)?; + let total_voting_power = query_total_voting_power_v1( + deps, + module_addrs.voting.clone().unwrap(), + sample_proposal_data.start_height, + )?; + let single_voting_power = query_single_voting_power_v1( + deps, + module_addrs.voting.unwrap(), + sample_proposal_data.proposer, + sample_proposal_data.start_height, + )?; + + Ok(TestState { + proposal_counts, + proposals, + total_voting_power, + single_voting_power, + }) +} + +fn query_state_v2(deps: Deps, module_addrs: ModulesAddrs) -> Result { + let proposal_counts = query_proposal_count_v2(deps, module_addrs.proposals.clone())?; + let (proposals, sample_proposal_data) = + query_proposal_v2(deps, module_addrs.proposals.clone())?; + let total_voting_power = query_total_voting_power_v2( + deps, + module_addrs.voting.clone().unwrap(), + sample_proposal_data.start_height, + )?; + let single_voting_power = query_single_voting_power_v2( + deps, + module_addrs.voting.unwrap(), + sample_proposal_data.proposer, + sample_proposal_data.start_height, + )?; + + Ok(TestState { + proposal_counts, + proposals, + total_voting_power, + single_voting_power, + }) +} + +fn test_state(deps: Deps) -> Result<(), ContractError> { + let old_state = TEST_STATE.load(deps.storage)?; + let modules_addrs = MODULES_ADDRS.load(deps.storage)?; + let new_state = query_state_v2(deps, modules_addrs)?; + + if new_state == old_state { + Ok(()) + } else { + Err(ContractError::TestFailed) + } +} diff --git a/contracts/external/dao-migrator/src/error.rs b/contracts/external/dao-migrator/src/error.rs new file mode 100644 index 000000000..c99a5cc94 --- /dev/null +++ b/contracts/external/dao-migrator/src/error.rs @@ -0,0 +1,48 @@ +use cosmwasm_std::StdError; +use cw_utils::ParseReplyError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + StdError(#[from] StdError), + + #[error(transparent)] + ParseReplyError(#[from] ParseReplyError), + + #[error("unauthorized")] + Unauthorized, + + #[error("Error querying ContractInfo at address: {address}")] + NoContractInfo { address: String }, + + #[error("Can't migrate module, code id is not recognized. code_id: {code_id}")] + CantMigrateModule { code_id: u64 }, + + #[error("unrecognised reply ID")] + UnrecognisedReplyId, + + #[error("Test failed! New DAO state doesn't match the old DAO state.")] + TestFailed, + + #[error("Failed to confirm migration of cw20_stake")] + DontMigrateCw20, + + #[error("Failed to verify DAO voting module address")] + VotingModuleNotFound, + + #[error("Failed to verify any DAO proposal single module address")] + DaoProposalSingleNotFound, + + #[error("We couldn't find the proposal modules in provided migration params: {addr}")] + ProposalModuleNotFoundInParams { addr: String }, + + #[error("Failed to verify proposal in {module_addr}")] + NoProposalsOnModule { module_addr: String }, + + #[error("Duplicate params found for the same module")] + DuplicateProposalParams, + + #[error("Proposal migration params length is not equal to proposal modules length")] + MigrationParamsNotEqualProposalModulesLength, +} diff --git a/contracts/external/dao-migrator/src/lib.rs b/contracts/external/dao-migrator/src/lib.rs new file mode 100644 index 000000000..6eafcb020 --- /dev/null +++ b/contracts/external/dao-migrator/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; +pub mod types; +pub mod utils; + +#[cfg(test)] +mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/external/dao-migrator/src/msg.rs b/contracts/external/dao-migrator/src/msg.rs new file mode 100644 index 000000000..8cca0a3c7 --- /dev/null +++ b/contracts/external/dao-migrator/src/msg.rs @@ -0,0 +1,20 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use dao_interface::query::SubDao; + +use crate::types::{MigrationParams, V1CodeIds, V2CodeIds}; + +#[cw_serde] +pub struct MigrateV1ToV2 { + pub sub_daos: Vec, + pub migration_params: MigrationParams, + pub v1_code_ids: V1CodeIds, + pub v2_code_ids: V2CodeIds, +} + +pub type InstantiateMsg = MigrateV1ToV2; + +pub type ExecuteMsg = MigrateV1ToV2; + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg {} diff --git a/contracts/external/dao-migrator/src/state.rs b/contracts/external/dao-migrator/src/state.rs new file mode 100644 index 000000000..729a1ec96 --- /dev/null +++ b/contracts/external/dao-migrator/src/state.rs @@ -0,0 +1,11 @@ +use cosmwasm_std::Addr; +use cw_storage_plus::Item; + +use crate::types::{ModulesAddrs, TestState}; + +/// Holds data about the DAO before migration (so we can test against it after migration) +pub const TEST_STATE: Item = Item::new("test_state"); +/// Holds addresses for what we need to query for +pub const MODULES_ADDRS: Item = Item::new("module_addrs"); +/// Hold the core address to be used in reply +pub const CORE_ADDR: Item = Item::new("core_addr"); diff --git a/contracts/external/dao-migrator/src/testing/helpers.rs b/contracts/external/dao-migrator/src/testing/helpers.rs new file mode 100644 index 000000000..974d5d7ed --- /dev/null +++ b/contracts/external/dao-migrator/src/testing/helpers.rs @@ -0,0 +1,353 @@ +use cosmwasm_std::{ + to_binary, Addr, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult, Uint128, + WasmMsg, +}; +use cw_multi_test::{next_block, App, Contract, ContractWrapper, Executor}; +use dao_interface::query::SubDao; +use dao_testing::contracts::{ + cw20_base_contract, cw20_staked_balances_voting_contract, cw4_group_contract, dao_dao_contract, + proposal_single_contract, v1_dao_dao_contract, v1_proposal_single_contract, +}; + +use crate::{ + types::{V1CodeIds, V2CodeIds}, + ContractError, +}; + +pub(crate) const SENDER_ADDR: &str = "creator"; + +#[derive(Clone)] +pub struct CodeIds { + pub core: u64, + pub proposal_single: u64, + pub cw20_base: u64, + pub cw20_stake: u64, + pub cw20_voting: u64, + pub cw4_group: u64, + pub cw4_voting: u64, +} + +pub struct ExecuteParams { + pub sub_daos: Option>, + pub migrate_cw20: Option, +} + +#[derive(Clone, Debug)] +pub struct ModuleAddrs { + pub core: Addr, + pub proposals: Vec, + pub voting: Addr, + pub staking: Option, + pub token: Option, +} + +#[derive(Clone)] +pub enum VotingType { + Cw4, + Cw20, + Cw20V03, +} + +pub fn get_v1_code_ids(app: &mut App) -> (CodeIds, V1CodeIds) { + let code_ids = CodeIds { + core: app.store_code(v1_dao_dao_contract()), + proposal_single: app.store_code(v1_proposal_single_contract()), + cw20_base: app.store_code(cw20_base_contract()), + cw20_stake: app.store_code(v1_cw20_stake_contract()), + cw20_voting: app.store_code(cw20_staked_balances_voting_contract()), + cw4_group: app.store_code(cw4_group_contract()), + cw4_voting: app.store_code(v1_cw4_voting_contract()), + }; + + let v1_code_ids = V1CodeIds { + proposal_single: code_ids.proposal_single, + cw4_voting: code_ids.cw4_voting, + cw20_stake: code_ids.cw20_stake, + cw20_staked_balances_voting: code_ids.cw20_voting, + }; + (code_ids, v1_code_ids) +} + +pub fn get_v2_code_ids(app: &mut App) -> (CodeIds, V2CodeIds) { + let code_ids = CodeIds { + core: app.store_code(dao_dao_contract()), + proposal_single: app.store_code(proposal_single_contract()), + cw20_base: app.store_code(cw20_base_contract()), + cw20_stake: app.store_code(v2_cw20_stake_contract()), + cw20_voting: app.store_code(dao_voting_cw20_staked_contract()), + cw4_group: app.store_code(cw4_group_contract()), + cw4_voting: app.store_code(dao_voting_cw4_contract()), + }; + + let v2_code_ids = V2CodeIds { + proposal_single: code_ids.proposal_single, + cw4_voting: code_ids.cw4_voting, + cw20_stake: code_ids.cw20_stake, + cw20_staked_balances_voting: code_ids.cw20_voting, + }; + (code_ids, v2_code_ids) +} + +pub fn get_cw20_init_msg(code_ids: CodeIds) -> cw20_staked_balance_voting_v1::msg::InstantiateMsg { + cw20_staked_balance_voting_v1::msg::InstantiateMsg { + token_info: cw20_staked_balance_voting_v1::msg::TokenInfo::New { + code_id: code_ids.cw20_base, + label: "token".to_string(), + name: "name".to_string(), + symbol: "symbol".to_string(), + decimals: 6, + initial_balances: vec![cw20_v1::Cw20Coin { + address: SENDER_ADDR.to_string(), + amount: Uint128::new(2), + }], + marketing: None, + staking_code_id: code_ids.cw20_stake, + unstaking_duration: None, + initial_dao_balance: Some(Uint128::new(100)), + }, + active_threshold: None, + } +} + +pub fn get_cw4_init_msg(code_ids: CodeIds) -> cw4_voting_v1::msg::InstantiateMsg { + cw4_voting_v1::msg::InstantiateMsg { + cw4_group_code_id: code_ids.cw4_group, + initial_members: vec![cw4_v1::Member { + addr: SENDER_ADDR.to_string(), + weight: 100, + }], + } +} + +pub fn get_module_addrs(app: &mut App, core_addr: Addr) -> ModuleAddrs { + // Get modules addrs + let proposal_addrs: Vec = { + app.wrap() + .query_wasm_smart( + &core_addr, + &cw_core_v1::msg::QueryMsg::ProposalModules { + start_at: None, + limit: None, + }, + ) + .unwrap() + }; + + let voting_addr: Addr = app + .wrap() + .query_wasm_smart(&core_addr, &cw_core_v1::msg::QueryMsg::VotingModule {}) + .unwrap(); + + let staking_addr: Option = app + .wrap() + .query_wasm_smart( + &voting_addr, + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .ok(); + + let token_addr: Option = app + .wrap() + .query_wasm_smart( + &voting_addr, + &dao_voting_cw20_staked::msg::QueryMsg::TokenContract {}, + ) + .ok(); + + ModuleAddrs { + core: core_addr, + proposals: proposal_addrs, + staking: staking_addr, + voting: voting_addr, + token: token_addr, + } +} + +pub fn set_dummy_proposal(app: &mut App, sender: Addr, core_addr: Addr, proposal_addr: Addr) { + app.execute_contract( + sender, + proposal_addr, + &cw_proposal_single_v1::msg::ExecuteMsg::Propose { + title: "t".to_string(), + description: "d".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: core_addr.to_string(), + msg: to_binary(&cw_core_v1::msg::ExecuteMsg::UpdateCw20List { + to_add: vec![], + to_remove: vec![], + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap(); +} + +pub fn set_cw20_to_dao(app: &mut App, sender: Addr, addrs: ModuleAddrs) { + let token_addr = addrs.token.unwrap(); + let staking_addr = addrs.staking.unwrap(); + + // Stake tokens + app.execute_contract( + sender.clone(), + token_addr.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::new(1), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + app.update_block(next_block); + + // ---- + // create a proposal and add tokens to the treasury. + // ---- + + app.execute_contract( + sender.clone(), + addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Propose { + title: "t".to_string(), + description: "d".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: addrs.core.to_string(), + msg: to_binary(&cw_core_v1::msg::ExecuteMsg::UpdateCw20List { + to_add: vec![token_addr.to_string()], + to_remove: vec![], + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap(); + + app.execute_contract( + sender.clone(), + addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Vote { + proposal_id: 1, + vote: voting_v1::Vote::Yes, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + sender, + addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + let tokens: Vec = app + .wrap() + .query_wasm_smart( + &addrs.core, + &cw_core_v1::msg::QueryMsg::Cw20Balances { + start_at: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + tokens, + vec![cw_core_v1::query::Cw20BalanceResponse { + addr: token_addr, + balance: Uint128::new(100), + }] + ); +} + +pub fn dao_voting_cw20_staked_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw20_staked::contract::execute, + dao_voting_cw20_staked::contract::instantiate, + dao_voting_cw20_staked::contract::query, + ) + .with_reply(dao_voting_cw20_staked::contract::reply) + .with_migrate(dao_voting_cw20_staked::contract::migrate); + Box::new(contract) +} + +pub fn migrator_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply); + Box::new(contract) +} + +pub fn v1_cw20_stake_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_stake_v1::contract::execute, + cw20_stake_v1::contract::instantiate, + cw20_stake_v1::contract::query, + ); + Box::new(contract) +} + +pub fn v2_cw20_stake_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_stake::contract::execute, + cw20_stake::contract::instantiate, + cw20_stake::contract::query, + ) + .with_migrate(cw20_stake::contract::migrate); + Box::new(contract) +} + +pub fn v1_cw4_voting_contract() -> Box> { + let contract = ContractWrapper::new( + cw4_voting_v1::contract::execute, + cw4_voting_v1::contract::instantiate, + cw4_voting_v1::contract::query, + ) + .with_reply(cw4_voting_v1::contract::reply); + Box::new(contract) +} + +pub fn dao_voting_cw4_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw4::contract::execute, + dao_voting_cw4::contract::instantiate, + dao_voting_cw4::contract::query, + ) + .with_reply(dao_voting_cw4::contract::reply) + .with_migrate(dao_voting_cw4::contract::migrate); + Box::new(contract) +} + +fn some_init( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: (), +) -> Result { + Ok(Response::default()) +} +fn some_execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: (), +) -> Result { + Ok(Response::default()) +} +fn some_query(_deps: Deps, _env: Env, _msg: ()) -> StdResult { + Ok(Binary::default()) +} + +pub fn demo_contract() -> Box> { + Box::new(ContractWrapper::new(some_execute, some_init, some_query)) +} diff --git a/contracts/external/dao-migrator/src/testing/mod.rs b/contracts/external/dao-migrator/src/testing/mod.rs new file mode 100644 index 000000000..ff05f8a03 --- /dev/null +++ b/contracts/external/dao-migrator/src/testing/mod.rs @@ -0,0 +1,4 @@ +pub mod helpers; +pub mod setup; +pub mod state_helpers; +pub mod test_migration; diff --git a/contracts/external/dao-migrator/src/testing/setup.rs b/contracts/external/dao-migrator/src/testing/setup.rs new file mode 100644 index 000000000..b9d93950e --- /dev/null +++ b/contracts/external/dao-migrator/src/testing/setup.rs @@ -0,0 +1,452 @@ +use std::borrow::BorrowMut; + +use cosmwasm_std::{to_binary, Addr, WasmMsg}; +use cw_multi_test::{next_block, App, AppResponse, Executor}; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_testing::contracts::stake_cw20_v03_contract; + +use crate::{ + testing::helpers::get_module_addrs, + types::{MigrationParams, ProposalParams, V1CodeIds}, +}; + +use super::helpers::{ + get_cw20_init_msg, get_cw4_init_msg, get_v1_code_ids, get_v2_code_ids, migrator_contract, + set_cw20_to_dao, set_dummy_proposal, ExecuteParams, ModuleAddrs, VotingType, SENDER_ADDR, +}; + +pub fn init_v1(app: &mut App, sender: Addr, voting_type: VotingType) -> (Addr, V1CodeIds) { + let (mut code_ids, mut v1_code_ids) = get_v1_code_ids(app); + + let (voting_code_id, msg) = match voting_type { + VotingType::Cw4 => ( + code_ids.cw4_voting, + to_binary(&get_cw4_init_msg(code_ids.clone())).unwrap(), + ), + VotingType::Cw20 => ( + code_ids.cw20_voting, + to_binary(&get_cw20_init_msg(code_ids.clone())).unwrap(), + ), + VotingType::Cw20V03 => { + // The simple change we need to do is to swap the cw20_stake with the one in v0.3.0 + let v03_cw20_stake = app.store_code(stake_cw20_v03_contract()); + code_ids.cw20_stake = v03_cw20_stake; + v1_code_ids.cw20_stake = v03_cw20_stake; + + ( + code_ids.cw20_voting, + to_binary(&get_cw20_init_msg(code_ids.clone())).unwrap(), + ) + } + }; + + let core_addr = app + .instantiate_contract( + code_ids.core, + sender.clone(), + &cw_core_v1::msg::InstantiateMsg { + admin: Some(SENDER_ADDR.to_string()), + name: "n".to_string(), + description: "d".to_string(), + image_url: Some("i".to_string()), + automatically_add_cw20s: false, + automatically_add_cw721s: true, + voting_module_instantiate_info: cw_core_v1::msg::ModuleInstantiateInfo { + code_id: voting_code_id, + msg, + admin: cw_core_v1::msg::Admin::CoreContract {}, + label: "voting".to_string(), + }, + proposal_modules_instantiate_info: vec![cw_core_v1::msg::ModuleInstantiateInfo { + code_id: code_ids.proposal_single, + msg: to_binary(&cw_proposal_single_v1::msg::InstantiateMsg { + threshold: voting_v1::Threshold::AbsolutePercentage { + percentage: voting_v1::PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils_v1::Duration::Height(6), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + deposit_info: None, + }) + .unwrap(), + admin: cw_core_v1::msg::Admin::CoreContract {}, + label: "proposal".to_string(), + }], + initial_items: Some(vec![cw_core_v1::msg::InitialItem { + key: "key".to_string(), + value: "value".to_string(), + }]), + }, + &[], + "core", + Some(sender.to_string()), + ) + .unwrap(); + + app.update_block(next_block); + + app.execute( + sender, + WasmMsg::UpdateAdmin { + contract_addr: core_addr.to_string(), + admin: core_addr.to_string(), + } + .into(), + ) + .unwrap(); + + (core_addr, v1_code_ids) +} + +pub fn init_v1_with_multiple_proposals( + app: &mut App, + sender: Addr, + voting_type: VotingType, +) -> (Addr, V1CodeIds) { + let (mut code_ids, mut v1_code_ids) = get_v1_code_ids(app); + + let (voting_code_id, msg) = match voting_type { + VotingType::Cw4 => ( + code_ids.cw4_voting, + to_binary(&get_cw4_init_msg(code_ids.clone())).unwrap(), + ), + VotingType::Cw20 => ( + code_ids.cw20_voting, + to_binary(&get_cw20_init_msg(code_ids.clone())).unwrap(), + ), + VotingType::Cw20V03 => { + let v03_cw20_stake = app.store_code(stake_cw20_v03_contract()); + code_ids.cw20_stake = v03_cw20_stake; + v1_code_ids.cw20_stake = v03_cw20_stake; + + ( + code_ids.cw20_voting, + to_binary(&get_cw20_init_msg(code_ids.clone())).unwrap(), + ) + } + }; + + let core_addr = app + .instantiate_contract( + code_ids.core, + sender.clone(), + &cw_core_v1::msg::InstantiateMsg { + admin: Some(SENDER_ADDR.to_string()), + name: "n".to_string(), + description: "d".to_string(), + image_url: Some("i".to_string()), + automatically_add_cw20s: false, + automatically_add_cw721s: true, + voting_module_instantiate_info: cw_core_v1::msg::ModuleInstantiateInfo { + code_id: voting_code_id, + msg, + admin: cw_core_v1::msg::Admin::CoreContract {}, + label: "voting".to_string(), + }, + proposal_modules_instantiate_info: vec![ + cw_core_v1::msg::ModuleInstantiateInfo { + code_id: code_ids.proposal_single, + msg: to_binary(&cw_proposal_single_v1::msg::InstantiateMsg { + threshold: voting_v1::Threshold::AbsolutePercentage { + percentage: voting_v1::PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils_v1::Duration::Height(6), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + deposit_info: None, + }) + .unwrap(), + admin: cw_core_v1::msg::Admin::CoreContract {}, + label: "proposal".to_string(), + }, + cw_core_v1::msg::ModuleInstantiateInfo { + code_id: code_ids.proposal_single, + msg: to_binary(&cw_proposal_single_v1::msg::InstantiateMsg { + threshold: voting_v1::Threshold::AbsolutePercentage { + percentage: voting_v1::PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils_v1::Duration::Height(6), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + deposit_info: None, + }) + .unwrap(), + admin: cw_core_v1::msg::Admin::CoreContract {}, + label: "proposal".to_string(), + }, + ], + initial_items: Some(vec![cw_core_v1::msg::InitialItem { + key: "key".to_string(), + value: "value".to_string(), + }]), + }, + &[], + "core", + Some(sender.to_string()), + ) + .unwrap(); + + app.update_block(next_block); + + app.execute( + sender, + WasmMsg::UpdateAdmin { + contract_addr: core_addr.to_string(), + admin: core_addr.to_string(), + } + .into(), + ) + .unwrap(); + + (core_addr, v1_code_ids) +} + +/// Instantiate a basic DAO with proposal and voting modules. +pub fn setup_dao_v1(voting_type: VotingType) -> (App, ModuleAddrs, V1CodeIds) { + let mut app = App::default(); + let sender = Addr::unchecked(SENDER_ADDR); + + let (core_addr, v1_code_ids) = init_v1(app.borrow_mut(), sender.clone(), voting_type.clone()); + let module_addrs = get_module_addrs(app.borrow_mut(), core_addr); + + match voting_type { + VotingType::Cw4 => set_dummy_proposal( + app.borrow_mut(), + sender, + module_addrs.core.clone(), + module_addrs.proposals[0].clone(), + ), + VotingType::Cw20 => set_cw20_to_dao(app.borrow_mut(), sender, module_addrs.clone()), + // Same as Cw20 + VotingType::Cw20V03 => set_cw20_to_dao(app.borrow_mut(), sender, module_addrs.clone()), + }; + + (app, module_addrs, v1_code_ids) +} + +/// Instantiate a basic DAO with 2 proposal modules. +pub fn setup_dao_v1_multiple_proposals() -> (App, ModuleAddrs, V1CodeIds) { + let mut app = App::default(); + let sender = Addr::unchecked(SENDER_ADDR); + + let (core_addr, v1_code_ids) = + init_v1_with_multiple_proposals(app.borrow_mut(), sender.clone(), VotingType::Cw20); + let module_addrs = get_module_addrs(app.borrow_mut(), core_addr); + + set_cw20_to_dao(app.borrow_mut(), sender.clone(), module_addrs.clone()); + set_dummy_proposal( + app.borrow_mut(), + sender, + module_addrs.core.clone(), + module_addrs.proposals[1].clone(), + ); + + (app, module_addrs, v1_code_ids) +} + +pub fn execute_migration( + app: &mut App, + module_addrs: &ModuleAddrs, + v1_code_ids: V1CodeIds, + params: Option, + custom_proposal_params: Option>, +) -> Result { + let sender = Addr::unchecked(SENDER_ADDR); + let migrator_code_id = app.store_code(migrator_contract()); + let (new_code_ids, v2_code_ids) = get_v2_code_ids(app); + let params = params.unwrap_or_else(|| ExecuteParams { + sub_daos: Some(vec![]), + migrate_cw20: Some(true), + }); + + let proposal_params = if let Some(params) = custom_proposal_params { + params + } else { + module_addrs + .proposals + .iter() + .map(|addr| { + ( + addr.clone().into(), + ProposalParams { + close_proposal_on_execution_failure: true, + pre_propose_info: + dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, + }, + ) + }) + .collect::>() + }; + + app.execute_contract( + sender.clone(), + module_addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Propose { + title: "t2".to_string(), + description: "d2".to_string(), + msgs: vec![ + WasmMsg::Migrate { + contract_addr: module_addrs.core.to_string(), + new_code_id: new_code_ids.core, + msg: to_binary(&dao_interface::msg::MigrateMsg::FromV1 { + dao_uri: None, + params: None, + }) + .unwrap(), + } + .into(), + WasmMsg::Execute { + contract_addr: module_addrs.core.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::UpdateProposalModules { + to_add: vec![ModuleInstantiateInfo { + code_id: migrator_code_id, + msg: to_binary(&crate::msg::InstantiateMsg { + sub_daos: params.sub_daos.unwrap(), + migration_params: MigrationParams { + migrate_stake_cw20_manager: params.migrate_cw20, + proposal_params, + }, + v1_code_ids, + v2_code_ids, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "migrator".to_string(), + }], + to_disable: vec![], + }) + .unwrap(), + funds: vec![], + } + .into(), + ], + }, + &[], + ) + .unwrap(); + + let perposals: cw_proposal_single_v1::query::ProposalListResponse = app + .wrap() + .query_wasm_smart( + module_addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::QueryMsg::ReverseProposals { + start_before: None, + limit: Some(1), + }, + ) + .unwrap(); + let proposal_id = perposals.proposals.first().unwrap().id; + + app.execute_contract( + sender.clone(), + module_addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Vote { + proposal_id, + vote: voting_v1::Vote::Yes, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + sender, + module_addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Execute { proposal_id }, + &[], + ) +} + +pub fn execute_migration_from_core( + app: &mut App, + module_addrs: &ModuleAddrs, + v1_code_ids: V1CodeIds, + params: Option, +) -> Result { + let sender = Addr::unchecked(SENDER_ADDR); + let migrator_code_id = app.store_code(migrator_contract()); + let (new_code_ids, v2_code_ids) = get_v2_code_ids(app); + let params = params.unwrap_or_else(|| ExecuteParams { + sub_daos: Some(vec![]), + migrate_cw20: Some(true), + }); + + let proposal_params = module_addrs + .proposals + .iter() + .map(|addr| { + ( + addr.clone().into(), + dao_interface::migrate_msg::ProposalParams { + close_proposal_on_execution_failure: true, + pre_propose_info: + dao_interface::migrate_msg::PreProposeInfo::AnyoneMayPropose {}, + }, + ) + }) + .collect::>(); + + app.execute_contract( + sender.clone(), + module_addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Propose { + title: "t2".to_string(), + description: "d2".to_string(), + msgs: vec![WasmMsg::Migrate { + contract_addr: module_addrs.core.to_string(), + new_code_id: new_code_ids.core, + msg: to_binary(&dao_interface::msg::MigrateMsg::FromV1 { + dao_uri: None, + params: Some(dao_interface::migrate_msg::MigrateParams { + migrator_code_id, + params: dao_interface::migrate_msg::MigrateV1ToV2 { + sub_daos: params.sub_daos.unwrap(), + migration_params: dao_interface::migrate_msg::MigrationModuleParams { + migrate_stake_cw20_manager: params.migrate_cw20, + proposal_params, + }, + v1_code_ids: v1_code_ids.to(), + v2_code_ids: v2_code_ids.to(), + }, + }), + }) + .unwrap(), + } + .into()], + }, + &[], + ) + .unwrap(); + + let perposals: cw_proposal_single_v1::query::ProposalListResponse = app + .wrap() + .query_wasm_smart( + module_addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::QueryMsg::ReverseProposals { + start_before: None, + limit: Some(1), + }, + ) + .unwrap(); + let proposal_id = perposals.proposals.first().unwrap().id; + + app.execute_contract( + sender.clone(), + module_addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Vote { + proposal_id, + vote: voting_v1::Vote::Yes, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + sender, + module_addrs.proposals[0].clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Execute { proposal_id }, + &[], + ) +} diff --git a/contracts/external/dao-migrator/src/testing/state_helpers.rs b/contracts/external/dao-migrator/src/testing/state_helpers.rs new file mode 100644 index 000000000..451a28821 --- /dev/null +++ b/contracts/external/dao-migrator/src/testing/state_helpers.rs @@ -0,0 +1,235 @@ +use cosmwasm_std::{Addr, Uint128}; +use cw_multi_test::App; + +use crate::utils::query_helpers::{ + v1_expiration_to_v2, v1_status_to_v2, v1_threshold_to_v2, v1_votes_to_v2, +}; + +#[derive(PartialEq, Debug, Clone)] +pub struct TestState { + pub proposal_count: u64, + pub proposal: dao_proposal_single::proposal::SingleChoiceProposal, + pub total_power: Uint128, + pub single_power: Uint128, +} + +pub fn query_proposal_v1( + app: &mut App, + proposal_addr: Addr, +) -> (u64, dao_proposal_single::proposal::SingleChoiceProposal) { + // proposal count + let proposal_count: u64 = app + .wrap() + .query_wasm_smart( + proposal_addr.clone(), + &cw_proposal_single_v1::msg::QueryMsg::ProposalCount {}, + ) + .unwrap(); + + // query proposal + let proposal = app + .wrap() + .query_wasm_smart::( + proposal_addr, + &cw_proposal_single_v1::msg::QueryMsg::ListProposals { + start_after: None, + limit: None, + }, + ) + .unwrap() + .proposals[0] + .clone() + .proposal; + + let proposal = dao_proposal_single::proposal::SingleChoiceProposal { + title: proposal.title, + description: proposal.description, + proposer: proposal.proposer, + start_height: proposal.start_height, + min_voting_period: proposal.min_voting_period.map(v1_expiration_to_v2), + expiration: v1_expiration_to_v2(proposal.expiration), + threshold: v1_threshold_to_v2(proposal.threshold), + total_power: proposal.total_power, + msgs: proposal.msgs, + status: v1_status_to_v2(proposal.status), + votes: v1_votes_to_v2(proposal.votes), + allow_revoting: proposal.allow_revoting, + }; + + (proposal_count, proposal) +} + +pub fn query_proposal_v2( + app: &mut App, + proposal_addr: Addr, +) -> (u64, dao_proposal_single::proposal::SingleChoiceProposal) { + // proposal count + let proposal_count: u64 = app + .wrap() + .query_wasm_smart( + proposal_addr.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalCount {}, + ) + .unwrap(); + + // query proposal + let proposal = app + .wrap() + .query_wasm_smart::( + proposal_addr, + &dao_proposal_single::msg::QueryMsg::ListProposals { + start_after: None, + limit: None, + }, + ) + .unwrap() + .proposals[0] + .clone() + .proposal; + + (proposal_count, proposal) +} + +pub fn query_state_v1_cw20(app: &mut App, proposal_addr: Addr, voting_addr: Addr) -> TestState { + let (proposal_count, proposal) = query_proposal_v1(app, proposal_addr); + + // query total voting power + let total_power = app + .wrap() + .query_wasm_smart::( + voting_addr.clone(), + &cw20_staked_balance_voting_v1::msg::QueryMsg::TotalPowerAtHeight { + height: Some(proposal.start_height), + }, + ) + .unwrap() + .power; + + // query single voting power + let single_power = app + .wrap() + .query_wasm_smart::( + voting_addr, + &cw20_staked_balance_voting_v1::msg::QueryMsg::VotingPowerAtHeight { + address: proposal.proposer.to_string(), + height: Some(proposal.start_height), + }, + ) + .unwrap() + .power; + + TestState { + proposal_count, + proposal, + total_power, + single_power, + } +} + +pub fn query_state_v2_cw20(app: &mut App, proposal_addr: Addr, voting_addr: Addr) -> TestState { + let (proposal_count, proposal) = query_proposal_v2(app, proposal_addr); + + // query total voting power + let total_power = app + .wrap() + .query_wasm_smart::( + voting_addr.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::TotalPowerAtHeight { + height: Some(proposal.start_height), + }, + ) + .unwrap() + .power; + + // query single voting power + let single_power = app + .wrap() + .query_wasm_smart::( + voting_addr, + &dao_voting_cw20_staked::msg::QueryMsg::VotingPowerAtHeight { + address: proposal.proposer.to_string(), + height: Some(proposal.start_height), + }, + ) + .unwrap() + .power; + + TestState { + proposal_count, + proposal, + total_power, + single_power, + } +} + +pub fn query_state_v1_cw4(app: &mut App, proposal_addr: Addr, voting_addr: Addr) -> TestState { + let (proposal_count, proposal) = query_proposal_v1(app, proposal_addr); + + // query total voting power + let total_power = app + .wrap() + .query_wasm_smart::( + voting_addr.clone(), + &cw4_voting_v1::msg::QueryMsg::TotalPowerAtHeight { + height: Some(proposal.start_height), + }, + ) + .unwrap() + .power; + + // query single voting power + let single_power = app + .wrap() + .query_wasm_smart::( + voting_addr, + &cw4_voting_v1::msg::QueryMsg::VotingPowerAtHeight { + address: proposal.proposer.to_string(), + height: Some(proposal.start_height), + }, + ) + .unwrap() + .power; + + TestState { + proposal_count, + proposal, + total_power, + single_power, + } +} + +pub fn query_state_v2_cw4(app: &mut App, proposal_addr: Addr, voting_addr: Addr) -> TestState { + let (proposal_count, proposal) = query_proposal_v2(app, proposal_addr); + + // query total voting power + let total_power = app + .wrap() + .query_wasm_smart::( + voting_addr.clone(), + &dao_voting_cw4::msg::QueryMsg::TotalPowerAtHeight { + height: Some(proposal.start_height), + }, + ) + .unwrap() + .power; + + // query single voting power + let single_power = app + .wrap() + .query_wasm_smart::( + voting_addr, + &dao_voting_cw4::msg::QueryMsg::VotingPowerAtHeight { + address: proposal.proposer.to_string(), + height: Some(proposal.start_height), + }, + ) + .unwrap() + .power; + + TestState { + proposal_count, + proposal, + total_power, + single_power, + } +} diff --git a/contracts/external/dao-migrator/src/testing/test_migration.rs b/contracts/external/dao-migrator/src/testing/test_migration.rs new file mode 100644 index 000000000..cf936f7e4 --- /dev/null +++ b/contracts/external/dao-migrator/src/testing/test_migration.rs @@ -0,0 +1,363 @@ +use std::borrow::BorrowMut; + +use cosmwasm_std::Addr; +use cw_multi_test::Executor; +use dao_interface::{query::SubDao, state::ProposalModuleStatus}; + +use crate::{ + testing::{ + helpers::ExecuteParams, + helpers::VotingType, + setup::{execute_migration, execute_migration_from_core, setup_dao_v1}, + state_helpers::{ + query_state_v1_cw20, query_state_v1_cw4, query_state_v2_cw20, query_state_v2_cw4, + }, + }, + types::ProposalParams, + ContractError, +}; + +use super::{helpers::demo_contract, setup::setup_dao_v1_multiple_proposals}; + +pub fn basic_test(voting_type: VotingType, from_core: bool) { + let (mut app, module_addrs, v1_code_ids) = setup_dao_v1(voting_type.clone()); + + let mut test_state_v1 = match voting_type { + VotingType::Cw4 => query_state_v1_cw4( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting.clone(), + ), + VotingType::Cw20 => query_state_v1_cw20( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting.clone(), + ), + VotingType::Cw20V03 => query_state_v1_cw20( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting.clone(), + ), + }; + //NOTE: We add 1 to count because we create a new proposal in execute_migration + test_state_v1.proposal_count += 1; + + match from_core { + true => { + execute_migration_from_core(app.borrow_mut(), &module_addrs, v1_code_ids, None).unwrap() + } + false => { + execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None).unwrap() + } + }; + + let test_state_v2 = match voting_type { + VotingType::Cw4 => query_state_v2_cw4( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting, + ), + VotingType::Cw20 => query_state_v2_cw20( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting, + ), + VotingType::Cw20V03 => query_state_v2_cw20( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting, + ), + }; + + assert_eq!(test_state_v1, test_state_v2); + + let modules: Vec = app + .wrap() + .query_wasm_smart( + module_addrs.core, + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(modules.len(), 2); + assert_eq!(modules[0].address, module_addrs.proposals[0]); + assert_eq!(modules[1].status, ProposalModuleStatus::Disabled); +} + +#[test] +fn test_execute_migration() { + // Test basic migrator (not called from core) + basic_test(VotingType::Cw20, false); + basic_test(VotingType::Cw4, false); + basic_test(VotingType::Cw20V03, false); + + // Test basic migrator (called from core) + basic_test(VotingType::Cw20, true); + basic_test(VotingType::Cw4, true); + basic_test(VotingType::Cw20V03, true); +} + +#[test] +fn test_migrator_address_is_first() { + let (mut app, module_addrs, v1_code_ids) = setup_dao_v1(VotingType::Cw20); + + // We init some demo contracts so we can bump the contract addr to "contract1X" + // That way, when we do a migration, the newely created migrator contract address + // will be "contract11", because the proposal module address is "contract4" + // when we query the dao for "ProposalModules", the migrator address + // will appear first in the list ("contract11" < "contract4") + let demo_code_id = app.store_code(demo_contract()); + for _ in 0..6 { + app.instantiate_contract( + demo_code_id, + Addr::unchecked("some"), + &(), + &[], + "demo", + None, + ) + .unwrap(); + } + + let mut test_state_v1 = query_state_v1_cw20( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting.clone(), + ); + //NOTE: We add 1 to count because we create a new proposal in execute_migration + test_state_v1.proposal_count += 1; + + execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None).unwrap(); + + let test_state_v2 = query_state_v2_cw20( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting, + ); + + assert_eq!(test_state_v1, test_state_v2); + + let modules: Vec = app + .wrap() + .query_wasm_smart( + module_addrs.core, + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(modules.len(), 2); + assert_eq!(modules[1].address, module_addrs.proposals[0]); // proposal module + assert_eq!(modules[0].status, ProposalModuleStatus::Disabled); // migrator module +} + +#[test] +fn test_multiple_proposal_modules() { + let (mut app, module_addrs, v1_code_ids) = setup_dao_v1_multiple_proposals(); + + let mut test_state_v1 = query_state_v1_cw20( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting.clone(), + ); + //NOTE: We add 1 to count because we create a new proposal in execute_migration + test_state_v1.proposal_count += 1; + + execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None).unwrap(); + + let test_state_v2 = query_state_v2_cw20( + &mut app, + module_addrs.proposals[0].clone(), + module_addrs.voting, + ); + + assert_eq!(test_state_v1, test_state_v2); +} + +#[test] +fn test_duplicate_proposal_params() { + let (mut app, module_addrs, v1_code_ids) = setup_dao_v1_multiple_proposals(); + + // 2 pararms with the same addr + let custom_params = vec![ + ( + module_addrs.proposals[0].to_string(), + ProposalParams { + close_proposal_on_execution_failure: true, + pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, + }, + ), + ( + module_addrs.proposals[0].to_string(), + ProposalParams { + close_proposal_on_execution_failure: true, + pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, + }, + ), + ]; + + let err = execute_migration( + app.borrow_mut(), + &module_addrs, + v1_code_ids, + None, + Some(custom_params), + ) + .unwrap_err() + .downcast::() + .unwrap(); + + assert_eq!(err, ContractError::DuplicateProposalParams) +} + +#[test] +fn test_multiple_proposal_modules_failing() { + // Test single proposal with multiple proposal params. + let (mut app, mut module_addrs, v1_code_ids) = setup_dao_v1(VotingType::Cw20); + + // `module_addrs.proposals` is only used to set migration params based on how many proposals we have here + // Its safe to add/remove 2nd proposal in this tests because the actual proposals are taken within the contract + // and are not provided externally + module_addrs.proposals.push(Addr::unchecked("proposal2")); + let err = execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!( + err, + ContractError::MigrationParamsNotEqualProposalModulesLength + ); + + // Test multiple proposals with single proposal params. + let (mut app, mut module_addrs, v1_code_ids) = setup_dao_v1_multiple_proposals(); + + module_addrs.proposals.remove(1); + let err = execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!( + err, + ContractError::MigrationParamsNotEqualProposalModulesLength + ); +} + +#[test] +fn test_wrong_code_id() { + let (mut app, module_addrs, mut v1_code_ids) = setup_dao_v1(VotingType::Cw20); + let old_v1_code_ids = v1_code_ids.clone(); + v1_code_ids.proposal_single = 555; + let err = execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!( + err, + ContractError::CantMigrateModule { + code_id: old_v1_code_ids.proposal_single + } + ); + + let (mut app, module_addrs, mut v1_code_ids) = setup_dao_v1(VotingType::Cw20); + let old_v1_code_ids = v1_code_ids.clone(); + v1_code_ids.cw20_stake = 555; + let err = execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!( + err, + ContractError::CantMigrateModule { + code_id: old_v1_code_ids.cw20_stake + } + ); + + let (mut app, module_addrs, mut v1_code_ids) = setup_dao_v1(VotingType::Cw20); + v1_code_ids.cw20_staked_balances_voting = 555; + let err = execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!(err, ContractError::VotingModuleNotFound); + + let (mut app, module_addrs, mut v1_code_ids) = setup_dao_v1(VotingType::Cw4); + v1_code_ids.cw4_voting = 555; + let err = execute_migration(app.borrow_mut(), &module_addrs, v1_code_ids, None, None) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!(err, ContractError::VotingModuleNotFound); +} + +#[test] +fn test_dont_migrate_cw20() { + let (mut app, module_addrs, v1_code_ids) = setup_dao_v1(VotingType::Cw20); + + let err = execute_migration( + app.borrow_mut(), + &module_addrs, + v1_code_ids.clone(), + Some(ExecuteParams { + sub_daos: Some(vec![]), + migrate_cw20: None, + }), + None, + ) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!(err, ContractError::DontMigrateCw20); + + let err = execute_migration( + app.borrow_mut(), + &module_addrs, + v1_code_ids, + Some(ExecuteParams { + sub_daos: Some(vec![]), + migrate_cw20: Some(false), + }), + None, + ) + .unwrap_err() + .downcast::() + .unwrap(); + assert_eq!(err, ContractError::DontMigrateCw20); +} + +#[test] +fn test_sub_daos() { + let (mut app, module_addrs, v1_code_ids) = setup_dao_v1(VotingType::Cw20); + let sub_dao = SubDao { + addr: "sub_dao_1".to_string(), + charter: None, + }; + + execute_migration( + app.borrow_mut(), + &module_addrs, + v1_code_ids, + Some(ExecuteParams { + sub_daos: Some(vec![sub_dao.clone()]), + migrate_cw20: Some(true), + }), + None, + ) + .unwrap(); + + let sub_daos: Vec = app + .wrap() + .query_wasm_smart( + module_addrs.core, + &dao_interface::msg::QueryMsg::ListSubDaos { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(sub_daos, vec![sub_dao]); +} diff --git a/contracts/external/dao-migrator/src/types.rs b/contracts/external/dao-migrator/src/types.rs new file mode 100644 index 000000000..837ca736e --- /dev/null +++ b/contracts/external/dao-migrator/src/types.rs @@ -0,0 +1,131 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; + +use crate::ContractError; + +#[cw_serde] +pub struct V1CodeIds { + pub proposal_single: u64, + pub cw4_voting: u64, + pub cw20_stake: u64, + pub cw20_staked_balances_voting: u64, +} + +impl V1CodeIds { + pub fn to(self) -> dao_interface::migrate_msg::V1CodeIds { + dao_interface::migrate_msg::V1CodeIds { + proposal_single: self.proposal_single, + cw4_voting: self.cw4_voting, + cw20_stake: self.cw20_stake, + cw20_staked_balances_voting: self.cw20_staked_balances_voting, + } + } +} + +#[cw_serde] +pub struct V2CodeIds { + pub proposal_single: u64, + pub cw4_voting: u64, + pub cw20_stake: u64, + pub cw20_staked_balances_voting: u64, +} + +impl V2CodeIds { + pub fn to(self) -> dao_interface::migrate_msg::V2CodeIds { + dao_interface::migrate_msg::V2CodeIds { + proposal_single: self.proposal_single, + cw4_voting: self.cw4_voting, + cw20_stake: self.cw20_stake, + cw20_staked_balances_voting: self.cw20_staked_balances_voting, + } + } +} + +/// The params we need to provide for migration msgs +#[cw_serde] +pub struct ProposalParams { + pub close_proposal_on_execution_failure: bool, + pub pre_propose_info: dao_voting::pre_propose::PreProposeInfo, +} + +#[cw_serde] +pub struct MigrationParams { + // General + /// Rather or not to migrate the stake_cw20 contract and its + /// manager. If this is not set to true and a stake_cw20 + /// contract is detected in the DAO's configuration the + /// migration will be aborted. + pub migrate_stake_cw20_manager: Option, + /// List of (address, ProposalParams) where `address` is an + /// address of a proposal module currently part of the DAO. + pub proposal_params: Vec<(String, ProposalParams)>, +} + +/// Wrapper enum that helps us to hold different types of migration msgs +#[cw_serde] +#[serde(untagged)] +pub enum MigrationMsgs { + DaoProposalSingle(dao_proposal_single::msg::MigrateMsg), + DaoVotingCw4(dao_voting_cw4::msg::MigrateMsg), + Cw20Stake(cw20_stake::msg::MigrateMsg), + DaoVotingCw20Staked(dao_voting_cw20_staked::msg::MigrateMsg), +} + +/// Module data we need for migrations and tests. +#[derive(Clone)] +pub struct CodeIdPair { + /// The code id used in V1 module + pub v1_code_id: u64, + /// The new code id used in V2 + pub v2_code_id: u64, + /// The migration msg of the module + pub migrate_msg: MigrationMsgs, +} + +impl CodeIdPair { + pub fn new(v1_code_id: u64, v2_code_id: u64, migrate_msg: MigrationMsgs) -> CodeIdPair { + CodeIdPair { + v1_code_id, + v2_code_id, + migrate_msg, + } + } +} + +/// Hold module addresses to do queries on +#[cw_serde] +#[derive(Default)] +pub struct ModulesAddrs { + pub voting: Option, + pub proposals: Vec, +} + +impl ModulesAddrs { + pub fn verify(&self) -> Result<(), ContractError> { + if self.voting.is_none() { + return Err(ContractError::VotingModuleNotFound); + } + + if self.proposals.is_empty() { + return Err(ContractError::DaoProposalSingleNotFound); + } + Ok(()) + } +} + +// Test helper types + +pub struct SingleProposalData { + pub proposer: Addr, + pub start_height: u64, +} + +/// Data we use to test after migration (it is set before migration) +#[cw_serde] +pub struct TestState { + pub proposal_counts: Vec, + pub proposals: Vec, + pub total_voting_power: Uint128, + /// This is the voting power of the proposer of the sample proposal + pub single_voting_power: Uint128, +} diff --git a/contracts/external/dao-migrator/src/utils/mod.rs b/contracts/external/dao-migrator/src/utils/mod.rs new file mode 100644 index 000000000..6c7c87b1d --- /dev/null +++ b/contracts/external/dao-migrator/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod query_helpers; +pub mod state_queries; diff --git a/contracts/external/dao-migrator/src/utils/query_helpers.rs b/contracts/external/dao-migrator/src/utils/query_helpers.rs new file mode 100644 index 000000000..dc5a433cd --- /dev/null +++ b/contracts/external/dao-migrator/src/utils/query_helpers.rs @@ -0,0 +1,54 @@ +use cw_utils::Expiration; +use dao_voting::{ + status::Status, + threshold::{PercentageThreshold, Threshold}, + voting::Votes, +}; + +pub(crate) fn v1_expiration_to_v2(v1: cw_utils_v1::Expiration) -> Expiration { + match v1 { + cw_utils_v1::Expiration::AtHeight(height) => Expiration::AtHeight(height), + cw_utils_v1::Expiration::AtTime(time) => Expiration::AtTime(time), + cw_utils_v1::Expiration::Never {} => Expiration::Never {}, + } +} + +pub(crate) fn v1_percentage_threshold_to_v2( + v1: voting_v1::PercentageThreshold, +) -> PercentageThreshold { + match v1 { + voting_v1::PercentageThreshold::Majority {} => PercentageThreshold::Majority {}, + voting_v1::PercentageThreshold::Percent(p) => PercentageThreshold::Percent(p), + } +} + +pub(crate) fn v1_threshold_to_v2(v1: voting_v1::Threshold) -> Threshold { + match v1 { + voting_v1::Threshold::AbsolutePercentage { percentage } => Threshold::AbsolutePercentage { + percentage: v1_percentage_threshold_to_v2(percentage), + }, + voting_v1::Threshold::ThresholdQuorum { threshold, quorum } => Threshold::ThresholdQuorum { + threshold: v1_percentage_threshold_to_v2(threshold), + quorum: v1_percentage_threshold_to_v2(quorum), + }, + voting_v1::Threshold::AbsoluteCount { threshold } => Threshold::AbsoluteCount { threshold }, + } +} + +pub(crate) fn v1_status_to_v2(v1: voting_v1::Status) -> Status { + match v1 { + voting_v1::Status::Open => Status::Open, + voting_v1::Status::Rejected => Status::Rejected, + voting_v1::Status::Passed => Status::Passed, + voting_v1::Status::Executed => Status::Executed, + voting_v1::Status::Closed => Status::Closed, + } +} + +pub(crate) fn v1_votes_to_v2(v1: voting_v1::Votes) -> Votes { + Votes { + yes: v1.yes, + no: v1.no, + abstain: v1.abstain, + } +} diff --git a/contracts/external/dao-migrator/src/utils/state_queries.rs b/contracts/external/dao-migrator/src/utils/state_queries.rs new file mode 100644 index 000000000..52fcc8bfd --- /dev/null +++ b/contracts/external/dao-migrator/src/utils/state_queries.rs @@ -0,0 +1,201 @@ +use cosmwasm_std::{Addr, Deps, StdResult, Uint128}; + +use crate::{types::SingleProposalData, ContractError}; + +use super::query_helpers::{ + v1_expiration_to_v2, v1_status_to_v2, v1_threshold_to_v2, v1_votes_to_v2, +}; + +pub fn query_proposal_count_v1(deps: Deps, proposals_addrs: Vec) -> StdResult> { + proposals_addrs + .into_iter() + .map(|proposal_addr| { + deps.querier.query_wasm_smart( + proposal_addr, + &cw_proposal_single_v1::msg::QueryMsg::ProposalCount {}, + ) + }) + .collect() +} + +pub fn query_proposal_count_v2(deps: Deps, proposals_addrs: Vec) -> StdResult> { + proposals_addrs + .into_iter() + .map(|proposal_addr| { + deps.querier.query_wasm_smart( + proposal_addr, + &dao_proposal_single::msg::QueryMsg::ProposalCount {}, + ) + }) + .collect() +} + +pub fn query_proposal_v1( + deps: Deps, + proposals_addrs: Vec, +) -> Result< + ( + Vec, + SingleProposalData, + ), + ContractError, +> { + let mut sample_proposal = None; + + let proposals = proposals_addrs + .into_iter() + .map(|proposal_addr| { + let proposals: cw_proposal_single_v1::query::ProposalListResponse = + deps.querier.query_wasm_smart( + proposal_addr.clone(), + &cw_proposal_single_v1::msg::QueryMsg::ReverseProposals { + start_before: None, + limit: None, + }, + )?; + + // If we don't have a proposal in the module, we can't do tests, so bail out. + let proposal = if proposals.proposals.is_empty() { + Err(ContractError::NoProposalsOnModule { + module_addr: proposal_addr.to_string(), + }) + } else { + Ok(proposals.proposals[0].clone().proposal) + }?; + + if sample_proposal.is_none() { + sample_proposal = Some(SingleProposalData { + proposer: proposal.proposer.clone(), + start_height: proposal.start_height, + }); + } + + Ok(dao_proposal_single::proposal::SingleChoiceProposal { + title: proposal.title, + description: proposal.description, + proposer: proposal.proposer, + start_height: proposal.start_height, + min_voting_period: proposal.min_voting_period.map(v1_expiration_to_v2), + expiration: v1_expiration_to_v2(proposal.expiration), + threshold: v1_threshold_to_v2(proposal.threshold), + total_power: proposal.total_power, + msgs: proposal.msgs, + status: v1_status_to_v2(proposal.status), + votes: v1_votes_to_v2(proposal.votes), + allow_revoting: proposal.allow_revoting, + }) + }) + .collect::, ContractError>>( + )?; + + Ok((proposals, sample_proposal.unwrap())) +} + +pub fn query_proposal_v2( + deps: Deps, + proposals_addrs: Vec, +) -> Result< + ( + Vec, + SingleProposalData, + ), + ContractError, +> { + let mut sample_proposal = None; + + let proposals = proposals_addrs + .into_iter() + .map(|proposal_addr| { + let proposals: dao_proposal_single::query::ProposalListResponse = + deps.querier.query_wasm_smart( + proposal_addr.clone(), + &dao_proposal_single::msg::QueryMsg::ReverseProposals { + start_before: None, + limit: None, + }, + )?; + + let proposal = if proposals.proposals.is_empty() { + Err(ContractError::NoProposalsOnModule { + module_addr: proposal_addr.to_string(), + }) + } else { + Ok(proposals.proposals[0].clone().proposal) + }?; + + if sample_proposal.is_none() { + sample_proposal = Some(SingleProposalData { + proposer: proposal.proposer.clone(), + start_height: proposal.start_height, + }); + } + + Ok(proposal) + }) + .collect::, ContractError>>( + )?; + + Ok((proposals, sample_proposal.unwrap())) +} + +pub fn query_total_voting_power_v1( + deps: Deps, + voting_addr: Addr, + height: u64, +) -> StdResult { + let res: cw_core_interface_v1::voting::TotalPowerAtHeightResponse = + deps.querier.query_wasm_smart( + voting_addr, + &cw20_staked_balance_voting_v1::msg::QueryMsg::TotalPowerAtHeight { + height: Some(height), + }, + )?; + Ok(res.power) +} + +pub fn query_total_voting_power_v2( + deps: Deps, + voting_addr: Addr, + height: u64, +) -> StdResult { + let res: dao_interface::voting::TotalPowerAtHeightResponse = deps.querier.query_wasm_smart( + voting_addr, + &dao_voting_cw20_staked::msg::QueryMsg::TotalPowerAtHeight { + height: Some(height), + }, + )?; + Ok(res.power) +} + +pub fn query_single_voting_power_v1( + deps: Deps, + voting_addr: Addr, + address: Addr, + height: u64, +) -> StdResult { + let res: cw_core_interface_v1::voting::VotingPowerAtHeightResponse = + deps.querier.query_wasm_smart( + voting_addr, + &cw20_staked_balance_voting_v1::msg::QueryMsg::VotingPowerAtHeight { + address: address.into(), + height: Some(height), + }, + )?; + Ok(res.power) +} + +pub fn query_single_voting_power_v2( + deps: Deps, + voting_addr: Addr, + address: Addr, + height: u64, +) -> StdResult { + let res: dao_interface::voting::VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( + voting_addr, + &dao_voting_cw20_staked::msg::QueryMsg::VotingPowerAtHeight { + address: address.into(), + height: Some(height), + }, + )?; + Ok(res.power) +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/.cargo/config b/contracts/pre-propose/dao-pre-propose-approval-single/.cargo/config new file mode 100644 index 000000000..ab407a024 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/.cargo/config @@ -0,0 +1,4 @@ +[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.toml b/contracts/pre-propose/dao-pre-propose-approval-single/Cargo.toml new file mode 100644 index 000000000..659b2d7d7 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "dao-pre-propose-approval-single" +authors = ["ekez ", "Jake Hartnell "] +description = "A DAO DAO pre-propose module handling a proposal approval flow for for dao-proposal-single." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw-paginate-storage = { workspace = true } +dao-pre-propose-base = { workspace = true } +dao-voting = { workspace = true } +thiserror = { workspace = true } +dao-interface = { workspace = true } + +[dev-dependencies] +cw-denom = { workspace = true } +cw-multi-test = { workspace = true } +cw-utils = { workspace = true } +cw4-group = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +dao-dao-core = { workspace = true } +dao-proposal-hooks = { workspace = true } +dao-testing = { workspace = true } +dao-voting = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-proposal-single = { workspace = true } diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/README.md b/contracts/pre-propose/dao-pre-propose-approval-single/README.md new file mode 100644 index 000000000..84de2b614 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/README.md @@ -0,0 +1,73 @@ +# Single choice proposal approval contract + +This contract implements an approval flow for proposals, it also handles deposit logic. It works with the `cwd-proposal-single` proposal module. + +## Approval Logic + +This contract is instantatied with an `approver` address. This address is allowed to approve or reject the proposal. + +```text + ┌──────────┐ + │ │ + │ Account │ + │ │ + └─────┬────┘ + │ + │ Makes prop + ▼ +┌────────────────────────┐ ┌────────────────────────┐ +│ │ │ │ +│ Pre-propose Approval │ ◄─────────────┤ Approver Address │ +│ │ Approves │ │ +└───────────┬────────────┘ or rejects └────────────────────────┘ + │ + │ Creates prop + │ on approval + ▼ +┌────────────────────────┐ +│ │ +│ Proposal Single │ +│ │ +└───────────┬────────────┘ + │ + │ Normal voting + │ + ▼ +┌────────────────────────┐ +│ │ +│ Main DAO │ +│ │ +└────────────────────────┘ +``` + +The `approver` may also register a `ProposalSubmitHook`, which fires every time a proposal is submitted to the `cwd-pre-propose-approval-single` contract. + +## Deposit Logic + +It may accept either native ([bank +module](https://docs.cosmos.network/main/modules/bank/)), +[cw20](https://github.com/CosmWasm/cw-plus/tree/bc339368b1ee33c97c55a19d4cff983c7708ce36/packages/cw20) +tokens, or no tokens as a deposit. If a proposal deposit is enabled +the following refund strategies are avaliable: + +1. Never refund deposits. All deposits are sent to the DAO on proposal + completion. +2. Always refund deposits. Deposits are returned to the proposer on + proposal completion and even rejection by the `approver`. +3. Only refund passed proposals. Deposits are only returned to the + proposer if the proposal is approved and passes. Otherwise, they + are sent to the DAO. + +This module may also be configured to only accept proposals from +members (addresses with voting power) of the DAO. + +Here is a flowchart showing the proposal creation process using this +module: + +![](https://bafkreig42cxswefi2ks7vhrwyvkcnumbnwdk7ov643yaafm7loi6vh2gja.ipfs.nftstorage.link) + +### Resources + +More about the [pre-propose design](https://github.com/DA0-DA0/dao-contracts/wiki/Pre-propose-module-design). + +More about [pre-propose modules](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#pre-propose-modules). diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/examples/schema.rs b/contracts/pre-propose/dao-pre-propose-approval-single/examples/schema.rs new file mode 100644 index 000000000..514f8152e --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use dao_pre_propose_approval_single::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} 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 new file mode 100644 index 000000000..c11084723 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json @@ -0,0 +1,1893 @@ +{ + "contract_name": "dao-pre-propose-approval-single", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "extension", + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit requirements for this module. None if no deposit.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "extension": { + "description": "Extension for instantiation. The default implementation will do nothing with this data.", + "allOf": [ + { + "$ref": "#/definitions/InstantiateExt" + } + ] + }, + "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" + } + }, + "additionalProperties": false, + "definitions": { + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token address of the associated DAO's voting module. NOTE: in order to use the token address of the voting module the voting module must (1) use a cw20 token and (2) implement the `TokenContract {}` query type defined by `dao_dao_macros::token_query`. Failing to implement that and using this option will cause instantiation to fail.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "InstantiateExt": { + "type": "object", + "required": [ + "approver" + ], + "properties": { + "approver": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "UncheckedDenom": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Creates a new proposal in the pre-propose module. MSG will be serialized and used as the proposal creation message.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/ProposeMessage" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the configuration of this module. This will completely override the existing configuration. This new configuration will only apply to proposals created after the config is updated. Only the DAO may execute this message.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "open_proposal_submission": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Withdraws funds inside of this contract to the message sender. The contracts entire balance for the specifed DENOM is withdrawn to the message sender. Only the DAO may call this method.\n\nThis is intended only as an escape hatch in the event of a critical bug in this contract or it's proposal module. Withdrawing funds will cause future attempts to return proposal deposits to fail their transactions as the contract will have insufficent balance to return them. In the case of `cw-proposal-single` this transaction failure will cause the module to remove the pre-propose module from its proposal hook receivers.\n\nMore likely than not, this should NEVER BE CALLED unless a bug in this contract or the proposal module it is associated with has caused it to stop receiving proposal hook messages, or if a critical security vulnerability has been found that allows an attacker to drain proposal deposits.", + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "properties": { + "denom": { + "description": "The denom to withdraw funds for. If no denom is specified, the denomination currently configured for proposal deposits will be used.\n\nYou may want to specify a denomination here if you are withdrawing funds that were previously accepted for proposal deposits but are not longer used due to an `UpdateConfig` message being executed on the contract.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension message. Contracts that extend this one should put their custom execute logic here. The default implementation will do nothing if this variant is executed.", + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/ExecuteExt" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds a proposal submitted hook. Fires when a new proposal is submitted to the pre-propose contract. Only the DAO may call this method.", + "type": "object", + "required": [ + "add_proposal_submitted_hook" + ], + "properties": { + "add_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a proposal submitted hook. Only the DAO may call this method.", + "type": "object", + "required": [ + "remove_proposal_submitted_hook" + ], + "properties": { + "remove_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Handles proposal hook fired by the associated proposal module when a proposal is completed (ie executed or rejected). By default, the base contract will return deposits proposals, when they are closed, when proposals are executed, or, if it is refunding failed.", + "type": "object", + "required": [ + "proposal_completed_hook" + ], + "properties": { + "proposal_completed_hook": { + "type": "object", + "required": [ + "new_status", + "proposal_id" + ], + "properties": { + "new_status": { + "$ref": "#/definitions/Status" + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "additionalProperties": false + } + ] + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token address of the associated DAO's voting module. NOTE: in order to use the token address of the voting module the voting module must (1) use a cw20 token and (2) implement the `TokenContract {}` query type defined by `dao_dao_macros::token_query`. Failing to implement that and using this option will cause instantiation to fail.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "ExecuteExt": { + "oneOf": [ + { + "description": "Approve a proposal, only callable by approver", + "type": "object", + "required": [ + "approve" + ], + "properties": { + "approve": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reject a proposal, only callable by approver", + "type": "object", + "required": [ + "reject" + ], + "properties": { + "reject": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the approver, can only be called the current approver", + "type": "object", + "required": [ + "update_approver" + ], + "properties": { + "update_approver": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "ProposeMessage": { + "oneOf": [ + { + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "description", + "msgs", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "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": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Gets the proposal module that this pre propose module is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "proposal_module" + ], + "properties": { + "proposal_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the DAO (dao-dao-core) module this contract is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "dao" + ], + "properties": { + "dao": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the module's configuration.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the deposit info for the proposal identified by PROPOSAL_ID.", + "type": "object", + "required": [ + "deposit_info" + ], + "properties": { + "deposit_info": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of proposal submitted hooks.", + "type": "object", + "required": [ + "proposal_submitted_hooks" + ], + "properties": { + "proposal_submitted_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension for queries. The default implementation will do nothing if queried for will return `Binary::default()`.", + "type": "object", + "required": [ + "query_extension" + ], + "properties": { + "query_extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/QueryExt" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "QueryExt": { + "oneOf": [ + { + "description": "List the approver address", + "type": "object", + "required": [ + "approver" + ], + "properties": { + "approver": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A pending proposal", + "type": "object", + "required": [ + "pending_proposal" + ], + "properties": { + "pending_proposal": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "List of proposals awaiting approval", + "type": "object", + "required": [ + "pending_proposals" + ], + "properties": { + "pending_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "reverse_pending_proposals" + ], + "properties": { + "reverse_pending_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_before": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "migrate": null, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit required to create a proposal. If `None`, no deposit is required.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "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" + } + }, + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "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" + } + } + }, + "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" + }, + "deposit_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DepositInfoResponse", + "type": "object", + "required": [ + "proposer" + ], + "properties": { + "deposit_info": { + "description": "The deposit that has been paid for the specified proposal.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "description": "The address that created the proposal.", + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "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" + } + } + }, + "proposal_module": { + "$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" + }, + "proposal_submitted_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "query_extension": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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" + } + } +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/contract.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/contract.rs new file mode 100644 index 000000000..a0ad2b764 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/contract.rs @@ -0,0 +1,323 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Order, Response, StdResult, SubMsg, + WasmMsg, +}; +use cw2::set_contract_version; +use cw_paginate_storage::paginate_map_values; +use dao_pre_propose_base::{ + error::PreProposeError, msg::ExecuteMsg as ExecuteBase, state::PreProposeContract, +}; +use dao_voting::deposit::DepositRefundPolicy; +use dao_voting::proposal::SingleChoiceProposeMsg as ProposeMsg; + +use crate::msg::{ + ApproverProposeMessage, ExecuteExt, ExecuteMsg, InstantiateExt, InstantiateMsg, ProposeMessage, + ProposeMessageInternal, QueryExt, QueryMsg, +}; +use crate::state::{advance_approval_id, PendingProposal, APPROVER, PENDING_PROPOSALS}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-pre-propose-approval-single"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +type PrePropose = PreProposeContract; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let approver = deps.api.addr_validate(&msg.extension.approver)?; + APPROVER.save(deps.storage, &approver)?; + + let resp = PrePropose::default().instantiate(deps.branch(), env, info, msg)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(resp.add_attribute("approver", approver.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Propose { msg } => execute_propose(deps, env, info, msg), + + ExecuteMsg::AddProposalSubmittedHook { address } => { + execute_add_approver_hook(deps, info, address) + } + ExecuteMsg::RemoveProposalSubmittedHook { address } => { + execute_remove_approver_hook(deps, info, address) + } + + ExecuteMsg::Extension { msg } => match msg { + ExecuteExt::Approve { id } => execute_approve(deps, info, id), + ExecuteExt::Reject { id } => execute_reject(deps, info, id), + ExecuteExt::UpdateApprover { address } => execute_update_approver(deps, info, address), + }, + // Default pre-propose-base behavior for all other messages + _ => PrePropose::default().execute(deps, env, info, msg), + } +} + +pub fn execute_propose( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ProposeMessage, +) -> Result { + let pre_propose_base = PrePropose::default(); + let config = pre_propose_base.config.load(deps.storage)?; + + pre_propose_base.check_can_submit(deps.as_ref(), info.sender.clone())?; + + // Take deposit, if configured. + let deposit_messages = if let Some(ref deposit_info) = config.deposit_info { + deposit_info.check_native_deposit_paid(&info)?; + deposit_info.get_take_deposit_messages(&info.sender, &env.contract.address)? + } else { + vec![] + }; + + let approval_id = advance_approval_id(deps.storage)?; + + let propose_msg_internal = match msg { + ProposeMessage::Propose { + title, + description, + msgs, + } => ProposeMsg { + title, + description, + msgs, + proposer: Some(info.sender.to_string()), + }, + }; + + // Prepare proposal submitted hooks msg to notify approver. Make + // a proposal on the approver DAO to approve this pre-proposal + let hooks_msgs = + pre_propose_base + .proposal_submitted_hooks + .prepare_hooks(deps.storage, |a| { + let execute_msg = WasmMsg::Execute { + contract_addr: a.into_string(), + msg: to_binary(&ExecuteBase::::Propose { + msg: ApproverProposeMessage::Propose { + title: propose_msg_internal.title.clone(), + description: propose_msg_internal.description.clone(), + approval_id, + }, + })?, + funds: vec![], + }; + Ok(SubMsg::new(execute_msg)) + })?; + + // Save the proposal and its information as pending. + PENDING_PROPOSALS.save( + deps.storage, + approval_id, + &PendingProposal { + approval_id, + proposer: info.sender, + msg: propose_msg_internal, + deposit: config.deposit_info, + }, + )?; + + Ok(Response::default() + .add_messages(deposit_messages) + .add_submessages(hooks_msgs) + .add_attribute("method", "pre-propose") + .add_attribute("id", approval_id.to_string())) +} + +pub fn execute_approve( + deps: DepsMut, + info: MessageInfo, + id: u64, +) -> Result { + // Check sender is the approver + let approver = APPROVER.load(deps.storage)?; + if approver != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + // Load proposal and send propose message to the proposal module + let proposal = PENDING_PROPOSALS.may_load(deps.storage, id)?; + match proposal { + Some(proposal) => { + let proposal_module = PrePropose::default().proposal_module.load(deps.storage)?; + + // Snapshot the deposit for the proposal that we're about + // to create. + let proposal_id = deps.querier.query_wasm_smart( + &proposal_module, + &dao_interface::proposal::Query::NextProposalId {}, + )?; + PrePropose::default().deposits.save( + deps.storage, + proposal_id, + &(proposal.deposit, proposal.proposer), + )?; + + let propose_messsage = WasmMsg::Execute { + contract_addr: proposal_module.into_string(), + msg: to_binary(&ProposeMessageInternal::Propose(proposal.msg))?, + funds: vec![], + }; + PENDING_PROPOSALS.remove(deps.storage, id); + + Ok(Response::default() + .add_message(propose_messsage) + .add_attribute("method", "proposal_approved") + .add_attribute("approval_id", id.to_string()) + .add_attribute("proposal_id", proposal_id.to_string())) + } + None => Err(PreProposeError::ProposalNotFound {}), + } +} + +pub fn execute_reject( + deps: DepsMut, + info: MessageInfo, + id: u64, +) -> Result { + // Check sender is the approver + let approver = APPROVER.load(deps.storage)?; + if approver != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + let PendingProposal { + deposit, proposer, .. + } = PENDING_PROPOSALS + .may_load(deps.storage, id)? + .ok_or(PreProposeError::ProposalNotFound {})?; + + PENDING_PROPOSALS.remove(deps.storage, id); + + let messages = if let Some(ref deposit_info) = deposit { + // Refund can be issued if proposal if deposits are always + // refunded. `OnlyPassed` and `Never` refund deposit policies + // do not apply here. + if deposit_info.refund_policy == DepositRefundPolicy::Always { + deposit_info.get_return_deposit_message(&proposer)? + } else { + // If the proposer doesn't get the deposit, the DAO does. + let dao = PrePropose::default().dao.load(deps.storage)?; + deposit_info.get_return_deposit_message(&dao)? + } + } else { + vec![] + }; + + Ok(Response::default() + .add_attribute("method", "proposal_rejected") + .add_attribute("proposal", id.to_string()) + .add_attribute("deposit_info", to_binary(&deposit)?.to_string()) + .add_messages(messages)) +} + +pub fn execute_update_approver( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + // Check sender is the approver + let approver = APPROVER.load(deps.storage)?; + if approver != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + // Validate address and save new approver + let addr = deps.api.addr_validate(&address)?; + APPROVER.save(deps.storage, &addr)?; + + Ok(Response::default()) +} + +pub fn execute_add_approver_hook( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + let pre_propose_base = PrePropose::default(); + + let dao = pre_propose_base.dao.load(deps.storage)?; + let approver = APPROVER.load(deps.storage)?; + + // Check sender is the approver or the parent DAO + if approver != info.sender && dao != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + let addr = deps.api.addr_validate(&address)?; + pre_propose_base + .proposal_submitted_hooks + .add_hook(deps.storage, addr)?; + + Ok(Response::default()) +} + +pub fn execute_remove_approver_hook( + deps: DepsMut, + info: MessageInfo, + address: String, +) -> Result { + let pre_propose_base = PrePropose::default(); + + let dao = pre_propose_base.dao.load(deps.storage)?; + let approver = APPROVER.load(deps.storage)?; + + // Check sender is the approver or the parent DAO + if approver != info.sender && dao != info.sender { + return Err(PreProposeError::Unauthorized {}); + } + + // Validate address + let addr = deps.api.addr_validate(&address)?; + + // remove hook + pre_propose_base + .proposal_submitted_hooks + .remove_hook(deps.storage, addr)?; + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::QueryExtension { msg } => match msg { + QueryExt::Approver {} => to_binary(&APPROVER.load(deps.storage)?), + QueryExt::PendingProposal { id } => { + to_binary(&PENDING_PROPOSALS.load(deps.storage, id)?) + } + QueryExt::PendingProposals { start_after, limit } => to_binary(&paginate_map_values( + deps, + &PENDING_PROPOSALS, + start_after, + limit, + Order::Descending, + )?), + QueryExt::ReversePendingProposals { + start_before, + limit, + } => to_binary(&paginate_map_values( + deps, + &PENDING_PROPOSALS, + start_before, + limit, + Order::Ascending, + )?), + }, + _ => PrePropose::default().query(deps, env, msg), + } +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/lib.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/lib.rs new file mode 100644 index 000000000..47ba41700 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +// Exporting these means that contracts interacting with this one don't +// need an explicit dependency on the base contract to read queries. +pub use dao_pre_propose_base::msg::DepositInfoResponse; +pub use dao_pre_propose_base::state::Config; diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/msg.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/msg.rs new file mode 100644 index 000000000..8a4df00df --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/msg.rs @@ -0,0 +1,73 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Empty}; +use dao_pre_propose_base::msg::{ + ExecuteMsg as ExecuteBase, InstantiateMsg as InstantiateBase, QueryMsg as QueryBase, +}; +use dao_voting::proposal::SingleChoiceProposeMsg as ProposeMsg; + +#[cw_serde] +pub enum ApproverProposeMessage { + Propose { + title: String, + description: String, + approval_id: u64, + }, +} + +#[cw_serde] +pub enum ProposeMessage { + Propose { + title: String, + description: String, + msgs: Vec>, + }, +} + +#[cw_serde] +pub struct InstantiateExt { + pub approver: String, +} + +#[cw_serde] +pub enum ExecuteExt { + /// Approve a proposal, only callable by approver + Approve { id: u64 }, + /// Reject a proposal, only callable by approver + Reject { id: u64 }, + /// Updates the approver, can only be called the current approver + UpdateApprover { address: String }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryExt { + /// List the approver address + #[returns(cosmwasm_std::Addr)] + Approver {}, + /// A pending proposal + #[returns(crate::state::PendingProposal)] + PendingProposal { id: u64 }, + /// List of proposals awaiting approval + #[returns(Vec)] + PendingProposals { + start_after: Option, + limit: Option, + }, + #[returns(Vec)] + ReversePendingProposals { + start_before: Option, + limit: Option, + }, +} + +pub type InstantiateMsg = InstantiateBase; +pub type ExecuteMsg = ExecuteBase; +pub type QueryMsg = QueryBase; + +/// Internal version of the propose message that includes the +/// `proposer` field. The module will fill this in based on the sender +/// of the external message. +#[cw_serde] +pub(crate) enum ProposeMessageInternal { + Propose(ProposeMsg), +} diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/state.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/state.rs new file mode 100644 index 000000000..5c11aedf9 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/state.rs @@ -0,0 +1,32 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, StdResult, Storage}; +use cw_storage_plus::{Item, Map}; + +use dao_voting::deposit::CheckedDepositInfo; +use dao_voting::proposal::SingleChoiceProposeMsg as ProposeMsg; + +#[cw_serde] +pub struct PendingProposal { + /// The approval ID used to identify this pending proposal. + pub approval_id: u64, + /// The address that created the proposal. + pub proposer: Addr, + /// The propose message that ought to be executed on the proposal + /// message if this proposal is approved. + pub msg: ProposeMsg, + /// Snapshot of the deposit info at the time of proposal + /// submission. + pub deposit: Option, +} + +pub const APPROVER: Item = Item::new("approver"); +pub const PENDING_PROPOSALS: Map = Map::new("pending_proposals"); + +/// Used internally to track the current approval_id. +const CURRENT_ID: Item = Item::new("current_id"); + +pub(crate) fn advance_approval_id(store: &mut dyn Storage) -> StdResult { + let id: u64 = CURRENT_ID.may_load(store)?.unwrap_or_default() + 1; + CURRENT_ID.save(store, &id)?; + Ok(id) +} 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 new file mode 100644 index 000000000..2127cebf5 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs @@ -0,0 +1,1550 @@ +use cosmwasm_std::{coins, from_slice, to_binary, Addr, Coin, Empty, Uint128}; +use cw2::ContractVersion; +use cw20::Cw20Coin; +use cw_denom::UncheckedDenom; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor}; +use cw_utils::Duration; +use dao_interface::state::ProposalModule; +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::{ + deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + status::Status, + threshold::{PercentageThreshold, Threshold}, + voting::Vote, +}; + +use crate::{contract::*, msg::*, state::PendingProposal}; + +fn cw_dao_proposal_single_contract() -> Box> { + let contract = ContractWrapper::new( + dao_proposal_single::contract::execute, + dao_proposal_single::contract::instantiate, + dao_proposal_single::contract::query, + ) + .with_migrate(dao_proposal_single::contract::migrate) + .with_reply(dao_proposal_single::contract::reply); + Box::new(contract) +} + +fn cw_pre_propose_base_proposal_single() -> Box> { + let contract = ContractWrapper::new(execute, instantiate, query); + Box::new(contract) +} + +fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn get_default_proposal_module_instantiate( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> dao_proposal_single::msg::InstantiateMsg { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + dao_proposal_single::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils::Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info, + open_proposal_submission, + extension: InstantiateExt { + approver: "approver".to_string(), + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } +} + +fn instantiate_cw20_base_default(app: &mut App) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }], + mint: None, + marketing: None, + }; + app.instantiate_contract( + cw20_id, + Addr::unchecked("ekez"), + &cw20_instantiate, + &[], + "cw20-base", + None, + ) + .unwrap() +} + +struct DefaultTestSetup { + core_addr: Addr, + proposal_single: Addr, + pre_propose: Addr, +} + +fn setup_default_test( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> DefaultTestSetup { + let dao_proposal_single_id = app.store_code(cw_dao_proposal_single_contract()); + + let proposal_module_instantiate = + get_default_proposal_module_instantiate(app, deposit_info, open_proposal_submission); + + let core_addr = instantiate_with_cw4_groups_governance( + app, + dao_proposal_single_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(proposal_modules.len(), 1); + let proposal_single = proposal_modules.into_iter().next().unwrap().address; + let proposal_creation_policy = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Module { addr } => addr, + _ => panic!("expected a module for the proposal creation policy"), + }; + + // Make sure things were set up correctly. + assert_eq!( + proposal_single, + get_proposal_module(app, pre_propose.clone()) + ); + assert_eq!(core_addr, get_dao(app, pre_propose.clone())); + + DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } +} + +fn make_pre_proposal(app: &mut App, pre_propose: Addr, proposer: &str, funds: &[Coin]) -> u64 { + app.execute_contract( + Addr::unchecked(proposer), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + }, + }, + funds, + ) + .unwrap(); + + // Query for pending proposal and return latest id + let mut pending: Vec = app + .wrap() + .query_wasm_smart( + pre_propose, + &QueryMsg::QueryExtension { + msg: QueryExt::PendingProposals { + start_after: None, + limit: None, + }, + }, + ) + .unwrap(); + + // Return last item in list, id is first element of tuple + pending.pop().unwrap().approval_id +} + +fn mint_natives(app: &mut App, receiver: &str, coins: Vec) { + // Mint some ekez tokens for ekez so we can pay the deposit. + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: receiver.to_string(), + amount: coins, + })) + .unwrap(); +} + +fn increase_allowance(app: &mut App, sender: &str, receiver: &Addr, cw20: Addr, amount: Uint128) { + app.execute_contract( + Addr::unchecked(sender), + cw20, + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: receiver.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); +} + +fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn get_balance_native(app: &App, who: &str, denom: &str) -> Uint128 { + let res = app.wrap().query_balance(who, denom).unwrap(); + res.amount +} + +fn vote(app: &mut App, module: Addr, sender: &str, id: u64, position: Vote) -> Status { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &dao_proposal_single::msg::ExecuteMsg::Vote { + proposal_id: id, + vote: position, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart( + module, + &dao_proposal_single::msg::QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(); + + proposal.proposal.status +} + +fn get_config(app: &App, module: Addr) -> Config { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Config {}) + .unwrap() +} + +fn get_dao(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Dao {}) + .unwrap() +} + +fn get_proposal_module(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::ProposalModule {}) + .unwrap() +} + +fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::DepositInfo { proposal_id: id }) + .unwrap() +} + +fn update_config( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + open_proposal_submission: bool, +) -> Config { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + &[], + ) + .unwrap(); + + get_config(app, module) +} + +fn update_config_should_fail( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + open_proposal_submission: bool, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn withdraw(app: &mut App, module: Addr, sender: &str, denom: Option) { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap(); +} + +fn withdraw_should_fail( + app: &mut App, + module: Addr, + sender: &str, + denom: Option, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn close_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &dao_proposal_single::msg::ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap(); +} + +fn execute_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &dao_proposal_single::msg::ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); +} + +fn approve_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) -> u64 { + let res = app + .execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: proposal_id }, + }, + &[], + ) + .unwrap(); + + // Parse attrs from approve_proposal response + let attrs = res.custom_attrs(res.events.len() - 1); + // Return ID + attrs[attrs.len() - 2].value.parse().unwrap() +} + +fn reject_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Extension { + msg: ExecuteExt::Reject { id: proposal_id }, + }, + &[], + ) + .unwrap(); +} + +enum ApprovalStatus { + Approved, + Rejected, +} + +enum EndStatus { + Passed, + Failed, +} + +enum RefundReceiver { + Proposer, + Dao, +} + +fn test_native_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, + approval_status: ApprovalStatus, +) { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Make sure it went away. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(balance, Uint128::zero()); + + // Approver approves or rejects proposal + match approval_status { + ApprovalStatus::Approved => { + // Approver approves, new proposal id is returned + let id = approve_proposal(&mut app, pre_propose, "approver", pre_propose_id); + + // Voting happens on newly created proposal + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => (Vote::Yes, Status::Passed, execute_proposal), + EndStatus::Failed => (Vote::No, Status::Rejected, close_proposal), + }; + let new_status = vote(&mut app, proposal_single.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_single, "ekez", id); + } + ApprovalStatus::Rejected => { + // Proposal is rejected by approver + // No proposal is created so there is no voting + reject_proposal(&mut app, pre_propose, "approver", pre_propose_id); + } + }; + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_native(&app, "ekez", "ujuno"); + let dao_balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +fn test_cw20_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, + approval_status: ApprovalStatus, +) { + let mut app = App::default(); + + let cw20_address = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Make sure it went await. + let balance = get_balance_cw20(&app, cw20_address.clone(), "ekez"); + assert_eq!(balance, Uint128::zero()); + + // Approver approves or rejects proposal + match approval_status { + ApprovalStatus::Approved => { + // Approver approves, new proposal id is returned + let id = approve_proposal(&mut app, pre_propose.clone(), "approver", pre_propose_id); + + // Voting happens on newly created proposal + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => (Vote::Yes, Status::Passed, execute_proposal), + EndStatus::Failed => (Vote::No, Status::Rejected, close_proposal), + }; + let new_status = vote(&mut app, proposal_single.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_single, "ekez", id); + } + ApprovalStatus::Rejected => { + // Proposal is rejected by approver + // No proposal is created so there is no voting + reject_proposal(&mut app, pre_propose.clone(), "approver", pre_propose_id); + } + }; + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_cw20(&app, &cw20_address, "ekez"); + let dao_balance = get_balance_cw20(&app, &cw20_address, core_addr); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +#[test] +fn test_native_failed_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_native_passed_always_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_always_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_passed_never_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_never_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_failed_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_native_passed_passed_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} +#[test] +fn test_cw20_passed_passed_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_failed_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +// See: +#[test] +fn test_multiple_open_proposals() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + let first_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Approver approves prop, balance remains the same + let first_id = approve_proposal( + &mut app, + pre_propose.clone(), + "approver", + first_pre_propose_id, + ); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + let second_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Approver approves prop, balance remains the same + let second_id = approve_proposal(&mut app, pre_propose, "approver", second_pre_propose_id); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Finish up the first proposal. + let new_status = vote( + &mut app, + proposal_single.clone(), + "ekez", + first_id, + Vote::Yes, + ); + assert_eq!(Status::Passed, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + execute_proposal(&mut app, proposal_single.clone(), "ekez", first_id); + + // First proposal refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Finish up the second proposal. + let new_status = vote( + &mut app, + proposal_single.clone(), + "ekez", + second_id, + Vote::No, + ); + assert_eq!(Status::Rejected, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + close_proposal(&mut app, proposal_single, "ekez", second_id); + + // All deposits have been refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(20, balance.u128()); +} + +#[test] +fn test_pending_proposal_queries() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Query for individual proposal + let prop1: PendingProposal = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::PendingProposal { id: 1 }, + }, + ) + .unwrap(); + assert_eq!(prop1.approval_id, 1); + + // Query for the pre-propose proposals + let pre_propose_props: Vec = app + .wrap() + .query_wasm_smart( + pre_propose.clone(), + &QueryMsg::QueryExtension { + msg: QueryExt::PendingProposals { + start_after: None, + limit: None, + }, + }, + ) + .unwrap(); + assert_eq!(pre_propose_props.len(), 2); + assert_eq!(pre_propose_props[0].approval_id, 2); + + // Query props in reverse + let reverse_pre_propose_props: Vec = app + .wrap() + .query_wasm_smart( + pre_propose, + &QueryMsg::QueryExtension { + msg: QueryExt::ReversePendingProposals { + start_before: None, + limit: None, + }, + }, + ) + .unwrap(); + + assert_eq!(reverse_pre_propose_props.len(), 2); + assert_eq!(reverse_pre_propose_props[0].approval_id, 1); +} + +#[test] +fn test_set_version() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + let info: ContractVersion = from_slice( + &app.wrap() + .query_wasm_raw(pre_propose, "contract_info".as_bytes()) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!( + ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string() + }, + info + ) +} + +#[test] +fn test_permissions() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, // no open proposal submission. + ); + + let err: PreProposeError = app + .execute_contract( + core_addr, + pre_propose.clone(), + &ExecuteMsg::ProposalCompletedHook { + proposal_id: 1, + new_status: Status::Closed, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotModule {}); + + // Non-members may not propose when open_propose_submission is + // disabled. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &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![], + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotMember {}); +} + +#[test] +fn test_approval_and_rejection_permissions() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal( + &mut app, + pre_propose.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + + // Only approver can propose + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Only approver can propose + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &ExecuteMsg::Extension { + msg: ExecuteExt::Reject { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); +} + +#[test] +fn test_propose_open_proposal_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal( + &mut app, + pre_propose.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + + // Approver approves + let id = approve_proposal(&mut app, pre_propose, "approver", pre_propose_id); + + // Member votes. + let new_status = vote(&mut app, proposal_single, "ekez", id, Vote::Yes); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_no_deposit_required_open_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, None, true, // yes, open proposal submission. + ); + + // Non-member proposes. + let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "nonmember", &[]); + + // Approver approves + let id = approve_proposal(&mut app, pre_propose, "approver", pre_propose_id); + + // Member votes. + let new_status = vote(&mut app, proposal_single, "ekez", id, Vote::Yes); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_no_deposit_required_members_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, None, false, // no open proposal submission. + ); + + // Non-member proposes and this fails. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + 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![], + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotMember {}); + + let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver approves + let id = approve_proposal(&mut app, pre_propose, "approver", pre_propose_id); + + let new_status = vote(&mut app, proposal_single, "ekez", id, Vote::Yes); + assert_eq!(Status::Passed, new_status) +} + +#[test] +#[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] +fn test_instantiate_with_zero_native_deposit() { + let mut app = App::default(); + + let dao_proposal_single_id = app.store_code(cw_dao_proposal_single_contract()); + + let proposal_module_instantiate = { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + dao_proposal_single::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::zero(), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: InstantiateExt { + approver: "approver".to_string(), + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } + }; + + // Should panic. + instantiate_with_cw4_groups_governance( + &mut app, + dao_proposal_single_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); +} + +#[test] +#[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] +fn test_instantiate_with_zero_cw20_deposit() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20_base_default(&mut app); + + let dao_proposal_single_id = app.store_code(cw_dao_proposal_single_contract()); + + let proposal_module_instantiate = { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + dao_proposal_single::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_addr.into_string()), + }, + amount: Uint128::zero(), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: InstantiateExt { + approver: "approver".to_string(), + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } + }; + + // Should panic. + instantiate_with_cw4_groups_governance( + &mut app, + dao_proposal_single_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); +} + +#[test] +fn test_update_config() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + open_proposal_submission: false + } + ); + + let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver approves + let id = approve_proposal(&mut app, pre_propose.clone(), "approver", pre_propose_id); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never, + }), + true, + ); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + open_proposal_submission: true, + } + ); + + // Old proposal should still have same deposit info. + let info = get_deposit_info(&app, pre_propose.clone(), id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: None, + proposer: Addr::unchecked("ekez"), + } + ); + + // New proposals should have the new deposit info. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let new_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Approver approves + let new_id = approve_proposal( + &mut app, + pre_propose.clone(), + "approver", + new_pre_propose_id, + ); + + let info = get_deposit_info(&app, pre_propose.clone(), new_id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + proposer: Addr::unchecked("ekez"), + } + ); + + // Both proposals should be allowed to complete. + vote(&mut app, proposal_single.clone(), "ekez", id, Vote::Yes); + vote(&mut app, proposal_single.clone(), "ekez", new_id, Vote::Yes); + execute_proposal(&mut app, proposal_single.clone(), "ekez", id); + execute_proposal(&mut app, proposal_single.clone(), "ekez", new_id); + // Deposit should not have been refunded (never policy in use). + let balance = get_balance_native(&app, "ekez", "ujuno"); + 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); + assert_eq!(err, PreProposeError::NotDao {}); +} + +#[test] +fn test_withdraw() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + proposal_single.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NotDao {}); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NothingToWithdraw {}); + + let err = withdraw_should_fail(&mut app, pre_propose.clone(), core_addr.as_str(), None); + assert_eq!(err, PreProposeError::NoWithdrawalDenom {}); + + // Turn on native deposits. + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + // Withdraw with no specified denom - should fall back to the one + // in the config. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(10)); + + // Withdraw again, this time specifying a native denomination. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(20)); + + // Make a proposal with the native tokens to put some in the system. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let native_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Approver approves + let native_id = approve_proposal( + &mut app, + pre_propose.clone(), + "approver", + native_pre_propose_id, + ); + + // Update the config to use a cw20 token. + let cw20_address = instantiate_cw20_base_default(&mut app); + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let cw20_pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver approves + let cw20_id = approve_proposal( + &mut app, + pre_propose.clone(), + "approver", + cw20_pre_propose_id, + ); + + // There is now a pending proposal and cw20 tokens in the + // pre-propose module that should be returned on that proposal's + // completion. To make things interesting, we withdraw those + // tokens which should cause the status change hook on the + // proposal's execution to fail as we don't have sufficent balance + // to return the deposit. + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_cw20(&app, &cw20_address, core_addr.as_str()); + assert_eq!(balance, Uint128::new(10)); + + // Proposal should still be executable! We just get removed from + // the proposal module's hook receiver list. + vote( + &mut app, + proposal_single.clone(), + "ekez", + cw20_id, + Vote::Yes, + ); + execute_proposal(&mut app, proposal_single.clone(), "ekez", cw20_id); + + // Make sure the proposal module has fallen back to anyone can + // propose becuase of our malfunction. + let proposal_creation_policy: ProposalCreationPolicy = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + assert_eq!(proposal_creation_policy, ProposalCreationPolicy::Anyone {}); + + // Close out the native proposal and it's deposit as well. + vote( + &mut app, + proposal_single.clone(), + "ekez", + native_id, + Vote::No, + ); + close_proposal(&mut app, proposal_single.clone(), "ekez", native_id); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(30)); +} diff --git a/contracts/pre-propose/dao-pre-propose-approver/.cargo/config b/contracts/pre-propose/dao-pre-propose-approver/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/pre-propose/dao-pre-propose-approver/Cargo.toml b/contracts/pre-propose/dao-pre-propose-approver/Cargo.toml new file mode 100644 index 000000000..05bc50642 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "dao-pre-propose-approver" +authors = ["ekez ", "Jake Hartnell "] +description = "A DAO DAO pre-propose module for automatically making approval proposals for dao-pre-propose-approval-single." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +dao-interface = { workspace = true } +dao-pre-propose-base = { workspace = true } +dao-pre-propose-approval-single = { workspace = true, features = ["library"] } +dao-voting = { workspace = true } + +[dev-dependencies] +cw-denom = { workspace = true } +cw-multi-test = { workspace = true } +cw-utils = { workspace = true } +cw4-group = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +dao-dao-core = { workspace = true } +dao-proposal-hooks = { workspace = true } +dao-proposal-single = { workspace = true, features = ["library"] } +dao-testing = { workspace = true } +dao-voting = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting-cw20-staked = { workspace = true } diff --git a/contracts/pre-propose/dao-pre-propose-approver/README.md b/contracts/pre-propose/dao-pre-propose-approver/README.md new file mode 100644 index 000000000..4d51bde8c --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/README.md @@ -0,0 +1,53 @@ +# Proposal Approver Contract + +This contract works in conjuction with `cwd-pre-propose-approval-single` and allows for automatically creating approval proposals when a proposal is submitted for approval. + +## Approver Logic + +On instantiation, this contract registers a hook with the approval contract to automatically create proposals in the approver DAO. + +When this contract recieves a proposal as hook from `cwd-pre-propose-approval-single`, it makes an approval propose in the approval DAO. If approved, the approval proposal calls the approve message on this contract when executed. If the proposal is rejected and closed it fires off reject call. + +```text +┌──────────┐ Approver DAO Registers Prop Submission Hook +│ │ ┌──────────────────────────────────────────────┐ +│ Account │ │ │ +│ │ │ │ +└─────┬────┘ │ Prop Submission Hook creates │ + │ │ new prop in Approver DAO │ + │ Makes prop │ ┌───────────────────────────┐ │ + ▼ ▼ │ ▼ │ +┌──────────────────────┴─┐ ┌────────────────────────┐ │ +│ │ │ │ │ +│ Pre-propose Approval │ │ Pre-propose Approver │ │ +│ │◄──┐ │ │ │ +└───────────┬────────────┘ │ └───────────┬────────────┘ │ + │ │ │ │ + │ Creates prop │ │ Creates │ + │ on approval │ │ prop │ + ▼ │ ▼ │ +┌────────────────────────┐ │ ┌────────────────────────┐ │ +│ │ │ │ │ │ +│ Proposal Single │ │ │ Proposal Single │ │ +│ │ │ │ │ │ +└───────────┬────────────┘ │ └───────────┬────────────┘ │ + │ │ Approver │ │ + │ Normal voting │ Approves │ Voting │ + │ │ or │ │ + ▼ │ Rejects ▼ │ +┌────────────────────────┐ │ ┌────────────────────────┐ │ +│ │ │ │ │ │ +│ Main DAO │ └─────────┤ Approver DAO ├─┘ +│ │ │ │ +└────────────────────────┘ └────────────────────────┘ +``` + +## Deposits + +This contract does not handle deposits. It works in conjunction with the `cwd-pre-propose-approval-single` contract, which handles the proposal deposits. + +### Resources + +More about the [pre-propose design](https://github.com/DA0-DA0/dao-contracts/wiki/Pre-propose-module-design). + +More about [pre-propose modules](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#pre-propose-modules). diff --git a/contracts/pre-propose/dao-pre-propose-approver/examples/schema.rs b/contracts/pre-propose/dao-pre-propose-approver/examples/schema.rs new file mode 100644 index 000000000..51cb0cbf3 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use dao_pre_propose_approver::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} 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 new file mode 100644 index 000000000..dafcc738c --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/schema/dao-pre-propose-approver.json @@ -0,0 +1,850 @@ +{ + "contract_name": "dao-pre-propose-approver", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "pre_propose_approval_contract" + ], + "properties": { + "pre_propose_approval_contract": { + "type": "string" + } + }, + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Creates a new proposal in the pre-propose module. MSG will be serialized and used as the proposal creation message.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/ApproverProposeMessage" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the configuration of this module. This will completely override the existing configuration. This new configuration will only apply to proposals created after the config is updated. Only the DAO may execute this message.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "open_proposal_submission": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Withdraws funds inside of this contract to the message sender. The contracts entire balance for the specifed DENOM is withdrawn to the message sender. Only the DAO may call this method.\n\nThis is intended only as an escape hatch in the event of a critical bug in this contract or it's proposal module. Withdrawing funds will cause future attempts to return proposal deposits to fail their transactions as the contract will have insufficent balance to return them. In the case of `cw-proposal-single` this transaction failure will cause the module to remove the pre-propose module from its proposal hook receivers.\n\nMore likely than not, this should NEVER BE CALLED unless a bug in this contract or the proposal module it is associated with has caused it to stop receiving proposal hook messages, or if a critical security vulnerability has been found that allows an attacker to drain proposal deposits.", + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "properties": { + "denom": { + "description": "The denom to withdraw funds for. If no denom is specified, the denomination currently configured for proposal deposits will be used.\n\nYou may want to specify a denomination here if you are withdrawing funds that were previously accepted for proposal deposits but are not longer used due to an `UpdateConfig` message being executed on the contract.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension message. Contracts that extend this one should put their custom execute logic here. The default implementation will do nothing if this variant is executed.", + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds a proposal submitted hook. Fires when a new proposal is submitted to the pre-propose contract. Only the DAO may call this method.", + "type": "object", + "required": [ + "add_proposal_submitted_hook" + ], + "properties": { + "add_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a proposal submitted hook. Only the DAO may call this method.", + "type": "object", + "required": [ + "remove_proposal_submitted_hook" + ], + "properties": { + "remove_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Handles proposal hook fired by the associated proposal module when a proposal is completed (ie executed or rejected). By default, the base contract will return deposits proposals, when they are closed, when proposals are executed, or, if it is refunding failed.", + "type": "object", + "required": [ + "proposal_completed_hook" + ], + "properties": { + "proposal_completed_hook": { + "type": "object", + "required": [ + "new_status", + "proposal_id" + ], + "properties": { + "new_status": { + "$ref": "#/definitions/Status" + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "ApproverProposeMessage": { + "oneOf": [ + { + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "approval_id", + "description", + "title" + ], + "properties": { + "approval_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token address of the associated DAO's voting module. NOTE: in order to use the token address of the voting module the voting module must (1) use a cw20 token and (2) implement the `TokenContract {}` query type defined by `dao_dao_macros::token_query`. Failing to implement that and using this option will cause instantiation to fail.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "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" + }, + "UncheckedDenom": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Gets the proposal module that this pre propose module is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "proposal_module" + ], + "properties": { + "proposal_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the DAO (dao-dao-core) module this contract is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "dao" + ], + "properties": { + "dao": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the module's configuration.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the deposit info for the proposal identified by PROPOSAL_ID.", + "type": "object", + "required": [ + "deposit_info" + ], + "properties": { + "deposit_info": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of proposal submitted hooks.", + "type": "object", + "required": [ + "proposal_submitted_hooks" + ], + "properties": { + "proposal_submitted_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension for queries. The default implementation will do nothing if queried for will return `Binary::default()`.", + "type": "object", + "required": [ + "query_extension" + ], + "properties": { + "query_extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/QueryExt" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "QueryExt": { + "oneOf": [ + { + "type": "object", + "required": [ + "pre_propose_approval_contract" + ], + "properties": { + "pre_propose_approval_contract": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "migrate": null, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit required to create a proposal. If `None`, no deposit is required.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "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" + } + }, + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "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" + } + } + }, + "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" + }, + "deposit_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DepositInfoResponse", + "type": "object", + "required": [ + "proposer" + ], + "properties": { + "deposit_info": { + "description": "The deposit that has been paid for the specified proposal.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "description": "The address that created the proposal.", + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "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" + } + } + }, + "proposal_module": { + "$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" + }, + "proposal_submitted_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "query_extension": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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" + } + } +} diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs b/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs new file mode 100644 index 000000000..c1e52ecd3 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs @@ -0,0 +1,194 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult, + WasmMsg, +}; +use cw2::set_contract_version; + +use dao_interface::state::ModuleInstantiateCallback; +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::status::Status; + +use crate::msg::{ + BaseInstantiateMsg, ExecuteMsg, InstantiateMsg, ProposeMessageInternal, QueryExt, QueryMsg, +}; +use crate::state::{PRE_PROPOSE_APPROVAL_CONTRACT, PROPOSAL_IDS}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-pre-propose-approver"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +type PrePropose = PreProposeContract; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + // This contract does not handle deposits or have open submissions + // Here we hardcode the pre-propose-base instantiate message + let base_instantiate_msg = BaseInstantiateMsg { + deposit_info: None, + open_proposal_submission: false, + extension: Empty {}, + }; + // Default pre-propose-base instantiation + let resp = PrePropose::default().instantiate( + deps.branch(), + env.clone(), + info, + base_instantiate_msg, + )?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // Validate and save the address of the pre-propose-approval-single contract + let addr = deps.api.addr_validate(&msg.pre_propose_approval_contract)?; + PRE_PROPOSE_APPROVAL_CONTRACT.save(deps.storage, &addr)?; + + Ok(resp.set_data(to_binary(&ModuleInstantiateCallback { + msgs: vec![ + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: addr.to_string(), + msg: to_binary(&PreProposeApprovalExecuteMsg::AddProposalSubmittedHook { + address: env.contract.address.to_string(), + })?, + funds: vec![], + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: addr.to_string(), + msg: to_binary(&PreProposeApprovalExecuteMsg::Extension { + msg: ApprovalExt::UpdateApprover { + address: env.contract.address.to_string(), + }, + })?, + funds: vec![], + }), + ], + })?)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + // Override default pre-propose-base behavior + ExecuteMsg::Propose { msg } => execute_propose(deps, info, msg), + ExecuteMsg::ProposalCompletedHook { + proposal_id, + new_status, + } => execute_proposal_completed(deps, info, proposal_id, new_status), + _ => PrePropose::default().execute(deps, env, info, msg), + } +} + +pub fn execute_propose( + deps: DepsMut, + info: MessageInfo, + msg: ApproverProposeMessage, +) -> Result { + // Check that this is coming from the expected approval contract + let approval_contract = PRE_PROPOSE_APPROVAL_CONTRACT.load(deps.storage)?; + if info.sender != approval_contract { + return Err(PreProposeError::Unauthorized {}); + } + + // Get pre_prospose_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 { + title, + description, + approval_id: pre_propose_id, + } => ( + pre_propose_id, + ProposeMessageInternal::Propose { + title, + description, + msgs: vec![], + proposer: Some(info.sender.to_string()), + }, + ), + }; + + let proposal_module = PrePropose::default().proposal_module.load(deps.storage)?; + let proposal_id = deps.querier.query_wasm_smart( + &proposal_module, + &dao_interface::proposal::Query::NextProposalId {}, + )?; + PROPOSAL_IDS.save(deps.storage, proposal_id, &pre_propose_id)?; + + let propose_messsage = WasmMsg::Execute { + contract_addr: proposal_module.into_string(), + msg: to_binary(&sanitized_msg)?, + funds: vec![], + }; + Ok(Response::default().add_message(propose_messsage)) +} + +pub fn execute_proposal_completed( + deps: DepsMut, + info: MessageInfo, + proposal_id: u64, + new_status: Status, +) -> Result { + // Safety check, this message can only come from the proposal module + let proposal_module = PrePropose::default().proposal_module.load(deps.storage)?; + if info.sender != proposal_module { + return Err(PreProposeError::NotModule {}); + } + + // Get approval pre-propose id + let pre_propose_id = PROPOSAL_IDS.load(deps.storage, proposal_id)?; + + // Get approval contract address + let approval_contract = PRE_PROPOSE_APPROVAL_CONTRACT.load(deps.storage)?; + + // On completion send rejection or approval message + let msg = match new_status { + Status::Closed => Some(WasmMsg::Execute { + contract_addr: approval_contract.into_string(), + msg: to_binary(&PreProposeApprovalExecuteMsg::Extension { + msg: ApprovalExt::Reject { id: pre_propose_id }, + })?, + funds: vec![], + }), + Status::Executed => Some(WasmMsg::Execute { + contract_addr: approval_contract.into_string(), + msg: to_binary(&PreProposeApprovalExecuteMsg::Extension { + msg: ApprovalExt::Approve { id: pre_propose_id }, + })?, + funds: vec![], + }), + _ => None, + }; + + // If Status is not Executed or Closed, throw error + match msg { + Some(msg) => Ok(Response::default() + .add_message(msg) + .add_attribute("method", "execute_proposal_completed_hook") + .add_attribute("proposal", proposal_id.to_string())), + None => Err(PreProposeError::NotClosedOrExecuted { status: new_status }), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::QueryExtension { msg } => match msg { + QueryExt::PreProposeApprovalContract {} => { + to_binary(&PRE_PROPOSE_APPROVAL_CONTRACT.load(deps.storage)?) + } + }, + _ => PrePropose::default().query(deps, env, msg), + } +} diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/lib.rs b/contracts/pre-propose/dao-pre-propose-approver/src/lib.rs new file mode 100644 index 000000000..47ba41700 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +// Exporting these means that contracts interacting with this one don't +// need an explicit dependency on the base contract to read queries. +pub use dao_pre_propose_base::msg::DepositInfoResponse; +pub use dao_pre_propose_base::state::Config; diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/msg.rs b/contracts/pre-propose/dao-pre-propose-approver/src/msg.rs new file mode 100644 index 000000000..249b45caa --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/src/msg.rs @@ -0,0 +1,35 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Empty}; +use dao_pre_propose_approval_single::msg::ApproverProposeMessage; +use dao_pre_propose_base::msg::{ + ExecuteMsg as ExecuteBase, InstantiateMsg as InstantiateBase, QueryMsg as QueryBase, +}; + +#[cw_serde] +pub struct InstantiateMsg { + pub pre_propose_approval_contract: String, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryExt { + #[returns(cosmwasm_std::Addr)] + PreProposeApprovalContract {}, +} + +pub type BaseInstantiateMsg = InstantiateBase; +pub type ExecuteMsg = ExecuteBase; +pub type QueryMsg = QueryBase; + +/// Internal version of the propose message that includes the +/// `proposer` field. The module will fill this in based on the sender +/// of the external message. +#[cw_serde] +pub enum ProposeMessageInternal { + Propose { + title: String, + description: String, + msgs: Vec>, + proposer: Option, + }, +} diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/state.rs b/contracts/pre-propose/dao-pre-propose-approver/src/state.rs new file mode 100644 index 000000000..b39187bf6 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/src/state.rs @@ -0,0 +1,7 @@ +use cosmwasm_std::Addr; +use cw_storage_plus::{Item, Map}; + +// Stores the address of the pre-propose approval contract +pub const PRE_PROPOSE_APPROVAL_CONTRACT: Item = Item::new("pre_propose_approval_contract"); +// Maps proposal ids to pre-propose ids +pub const PROPOSAL_IDS: Map = Map::new("proposal_ids"); diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs b/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs new file mode 100644 index 000000000..5aea7b2bc --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs @@ -0,0 +1,1543 @@ +use cosmwasm_std::{coins, from_slice, to_binary, Addr, Coin, Empty, Uint128}; +use cps::query::{ProposalListResponse, ProposalResponse}; +use cw2::ContractVersion; +use cw20::Cw20Coin; +use cw_denom::UncheckedDenom; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor}; + +use dao_interface::state::ProposalModule; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_pre_propose_approval_single::{ + msg::{ + ExecuteExt, ExecuteMsg, InstantiateExt, InstantiateMsg, ProposeMessage, QueryExt, QueryMsg, + }, + state::PendingProposal, +}; +use dao_pre_propose_base::{error::PreProposeError, msg::DepositInfoResponse, state::Config}; +use dao_proposal_single as cps; +use dao_testing::helpers::instantiate_with_cw4_groups_governance; +use dao_voting::{ + deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + status::Status, + threshold::{PercentageThreshold, Threshold}, + voting::Vote, +}; + +use crate::contract::{CONTRACT_NAME, CONTRACT_VERSION}; +use crate::msg::InstantiateMsg as ApproverInstantiateMsg; + +// The approver dao contract is the 6th contract instantiated +const APPROVER: &str = "contract6"; + +fn cw_dao_proposal_single_contract() -> Box> { + let contract = ContractWrapper::new( + cps::contract::execute, + cps::contract::instantiate, + cps::contract::query, + ) + .with_migrate(cps::contract::migrate) + .with_reply(cps::contract::reply); + Box::new(contract) +} + +fn cw_pre_propose_base_proposal_single() -> Box> { + let contract = ContractWrapper::new( + dao_pre_propose_approval_single::contract::execute, + dao_pre_propose_approval_single::contract::instantiate, + dao_pre_propose_approval_single::contract::query, + ); + Box::new(contract) +} + +fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn pre_propose_approver_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn get_proposal_module_approval_single_instantiate( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> cps::msg::InstantiateMsg { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + cps::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils::Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info, + open_proposal_submission, + extension: InstantiateExt { + approver: APPROVER.to_string(), + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module, needs supervision".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } +} + +fn get_proposal_module_approver_instantiate( + app: &mut App, + _deposit_info: Option, + _open_proposal_submission: bool, + pre_propose_approval_contract: String, +) -> cps::msg::InstantiateMsg { + let pre_propose_id = app.store_code(pre_propose_approver_contract()); + + cps::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils::Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&ApproverInstantiateMsg { + pre_propose_approval_contract, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "approver module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } +} + +fn instantiate_cw20_base_default(app: &mut App) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }], + mint: None, + marketing: None, + }; + app.instantiate_contract( + cw20_id, + Addr::unchecked("ekez"), + &cw20_instantiate, + &[], + "cw20-base", + None, + ) + .unwrap() +} + +struct DefaultTestSetup { + core_addr: Addr, + proposal_single: Addr, + pre_propose: Addr, + _approver_core_addr: Addr, + pre_propose_approver: Addr, + proposal_single_approver: Addr, +} + +fn setup_default_test( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> DefaultTestSetup { + let cps_id = app.store_code(cw_dao_proposal_single_contract()); + + // Instantiate SubDAO with pre-propose-approval-single + let proposal_module_instantiate = get_proposal_module_approval_single_instantiate( + app, + deposit_info.clone(), + open_proposal_submission, + ); + let core_addr = instantiate_with_cw4_groups_governance( + app, + cps_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + // Make sure things were set up correctly. + assert_eq!(proposal_modules.len(), 1); + let proposal_single = proposal_modules.into_iter().next().unwrap().address; + let proposal_creation_policy = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &cps::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Module { addr } => addr, + _ => panic!("expected a module for the proposal creation policy"), + }; + assert_eq!( + proposal_single, + get_proposal_module(app, pre_propose.clone()) + ); + assert_eq!(core_addr, get_dao(app, pre_propose.clone())); + + // Instantiate SubDAO with pre-propose-approver + let proposal_module_instantiate = get_proposal_module_approver_instantiate( + app, + deposit_info, + open_proposal_submission, + pre_propose.to_string(), + ); + + let _approver_core_addr = instantiate_with_cw4_groups_governance( + app, + cps_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + _approver_core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + // Make sure things were set up correctly. + assert_eq!(proposal_modules.len(), 1); + let proposal_single_approver = proposal_modules.into_iter().next().unwrap().address; + let proposal_creation_policy = app + .wrap() + .query_wasm_smart( + proposal_single_approver.clone(), + &cps::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + let pre_propose_approver = match proposal_creation_policy { + ProposalCreationPolicy::Module { addr } => addr, + _ => panic!("expected a module for the proposal creation policy"), + }; + assert_eq!( + proposal_single_approver, + get_proposal_module(app, pre_propose_approver.clone()) + ); + assert_eq!( + _approver_core_addr, + get_dao(app, pre_propose_approver.clone()) + ); + + DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + _approver_core_addr, + proposal_single_approver, + pre_propose_approver, + } +} + +fn make_pre_proposal(app: &mut App, pre_propose: Addr, proposer: &str, funds: &[Coin]) -> u64 { + app.execute_contract( + Addr::unchecked(proposer), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + }, + }, + funds, + ) + .unwrap(); + + // Query for pending proposal and return latest id + let mut pending: Vec = app + .wrap() + .query_wasm_smart( + pre_propose, + &QueryMsg::QueryExtension { + msg: QueryExt::PendingProposals { + start_after: None, + limit: None, + }, + }, + ) + .unwrap(); + + // Return last item in list, id is first element of tuple + pending.pop().unwrap().approval_id +} + +fn mint_natives(app: &mut App, receiver: &str, coins: Vec) { + // Mint some ekez tokens for ekez so we can pay the deposit. + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: receiver.to_string(), + amount: coins, + })) + .unwrap(); +} + +fn increase_allowance(app: &mut App, sender: &str, receiver: &Addr, cw20: Addr, amount: Uint128) { + app.execute_contract( + Addr::unchecked(sender), + cw20, + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: receiver.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); +} + +fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn get_balance_native(app: &App, who: &str, denom: &str) -> Uint128 { + let res = app.wrap().query_balance(who, denom).unwrap(); + res.amount +} + +fn vote(app: &mut App, module: Addr, sender: &str, id: u64, position: Vote) -> Status { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &cps::msg::ExecuteMsg::Vote { + proposal_id: id, + vote: position, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart(module, &cps::msg::QueryMsg::Proposal { proposal_id: id }) + .unwrap(); + + proposal.proposal.status +} + +fn get_config(app: &App, module: Addr) -> Config { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Config {}) + .unwrap() +} + +fn get_dao(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Dao {}) + .unwrap() +} + +fn get_proposal_module(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::ProposalModule {}) + .unwrap() +} + +fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::DepositInfo { proposal_id: id }) + .unwrap() +} + +fn get_proposals(app: &App, module: Addr) -> ProposalListResponse { + app.wrap() + .query_wasm_smart( + module, + &cps::msg::QueryMsg::ListProposals { + start_after: None, + limit: None, + }, + ) + .unwrap() +} + +fn get_latest_proposal_id(app: &App, module: Addr) -> u64 { + // Check prop was created in the main DAO + let props: ProposalListResponse = app + .wrap() + .query_wasm_smart( + module, + &cps::msg::QueryMsg::ListProposals { + start_after: None, + limit: None, + }, + ) + .unwrap(); + props.proposals[props.proposals.len() - 1].id +} + +fn update_config( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + open_proposal_submission: bool, +) -> Config { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + &[], + ) + .unwrap(); + + get_config(app, module) +} + +fn update_config_should_fail( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + open_proposal_submission: bool, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn withdraw(app: &mut App, module: Addr, sender: &str, denom: Option) { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap(); +} + +fn withdraw_should_fail( + app: &mut App, + module: Addr, + sender: &str, + denom: Option, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn close_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &cps::msg::ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap(); +} + +fn execute_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &cps::msg::ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); +} + +fn approve_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + // Approver votes on prop + vote(app, module.clone(), sender, proposal_id, Vote::Yes); + // Approver executes prop + execute_proposal(app, module, sender, proposal_id); +} + +enum ApprovalStatus { + Approved, + Rejected, +} + +enum EndStatus { + Passed, + Failed, +} + +enum RefundReceiver { + Proposer, + Dao, +} + +fn test_native_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, + approval_status: ApprovalStatus, +) { + 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, + proposal_single, + pre_propose, + _approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let _pre_propose_id = make_pre_proposal(&mut app, pre_propose, "ekez", &coins(10, "ujuno")); + + // Check no props created on main DAO yet + let props = get_proposals(&app, proposal_single.clone()); + assert_eq!(props.proposals.len(), 0); + + // Make sure it went away. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(balance, Uint128::zero()); + + // Approver approves or rejects proposal + match approval_status { + ApprovalStatus::Approved => { + // Get approver proposal id + let id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + + // Approver votes on prop + vote( + &mut app, + proposal_single_approver.clone(), + "ekez", + id, + Vote::Yes, + ); + // Approver executes prop + execute_proposal(&mut app, proposal_single_approver, "ekez", id); + + // Check prop was created in the main DAO + let id = get_latest_proposal_id(&app, proposal_single.clone()); + let props = get_proposals(&app, proposal_single.clone()); + assert_eq!(props.proposals.len(), 1); + + // Voting happens on newly created proposal + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => (Vote::Yes, Status::Passed, execute_proposal), + EndStatus::Failed => (Vote::No, Status::Rejected, close_proposal), + }; + let new_status = vote(&mut app, proposal_single.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_single, "ekez", id); + } + ApprovalStatus::Rejected => { + // Approver votes on prop + // No proposal is created so there is no voting + vote( + &mut app, + proposal_single_approver.clone(), + "ekez", + 1, + Vote::No, + ); + // Approver executes prop + close_proposal(&mut app, proposal_single_approver, "ekez", 1); + + // No prop created + let props = get_proposals(&app, proposal_single); + assert_eq!(props.proposals.len(), 0); + } + }; + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_native(&app, "ekez", "ujuno"); + let dao_balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +fn test_cw20_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, + approval_status: ApprovalStatus, +) { + let mut app = App::default(); + + let cw20_address = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + _approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let _pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Check no props created on main DAO yet + let props = get_proposals(&app, proposal_single.clone()); + assert_eq!(props.proposals.len(), 0); + + // Make sure it went await. + let balance = get_balance_cw20(&app, cw20_address.clone(), "ekez"); + assert_eq!(balance, Uint128::zero()); + + // Approver approves or rejects proposal + match approval_status { + ApprovalStatus::Approved => { + // Get approver proposal id + let id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + + // Approver votes on prop + vote( + &mut app, + proposal_single_approver.clone(), + "ekez", + id, + Vote::Yes, + ); + // Approver executes prop + execute_proposal(&mut app, proposal_single_approver, "ekez", id); + + // Check prop was created in the main DAO + let id = get_latest_proposal_id(&app, proposal_single.clone()); + let props = get_proposals(&app, proposal_single.clone()); + assert_eq!(props.proposals.len(), 1); + + // Voting happens on newly created proposal + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => (Vote::Yes, Status::Passed, execute_proposal), + EndStatus::Failed => (Vote::No, Status::Rejected, close_proposal), + }; + let new_status = vote(&mut app, proposal_single.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_single, "ekez", id); + } + ApprovalStatus::Rejected => { + // Approver votes on prop + // No proposal is created so there is no voting + vote( + &mut app, + proposal_single_approver.clone(), + "ekez", + 1, + Vote::No, + ); + // Approver executes prop + close_proposal(&mut app, proposal_single_approver, "ekez", 1); + + // No prop created + let props = get_proposals(&app, proposal_single); + assert_eq!(props.proposals.len(), 0); + } + }; + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_cw20(&app, &cw20_address, "ekez"); + let dao_balance = get_balance_cw20(&app, &cw20_address, core_addr); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +#[test] +fn test_native_failed_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_native_passed_always_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_always_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_passed_never_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_never_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_failed_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_native_passed_passed_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_passed_passed_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_failed_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_native_rejected_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +#[test] +fn test_cw20_failed_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Approved, + ) +} + +#[test] +fn test_cw20_rejected_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ApprovalStatus::Rejected, + ) +} + +// See: +#[test] +fn test_multiple_open_proposals() { + 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: _, + proposal_single, + pre_propose, + _approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + let _first_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Approver DAO approves prop, balance remains the same + let approver_prop_id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal( + &mut app, + proposal_single_approver.clone(), + "ekez", + approver_prop_id, + ); + let first_id = get_latest_proposal_id(&app, proposal_single.clone()); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + let _second_pre_propose_id = + make_pre_proposal(&mut app, pre_propose, "ekez", &coins(10, "ujuno")); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Approver DAO votes to approves, balance remains the same + let approver_prop_id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal(&mut app, proposal_single_approver, "ekez", approver_prop_id); + let second_id = get_latest_proposal_id(&app, proposal_single.clone()); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Finish up the first proposal. + let new_status = vote( + &mut app, + proposal_single.clone(), + "ekez", + first_id, + Vote::Yes, + ); + assert_eq!(Status::Passed, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + execute_proposal(&mut app, proposal_single.clone(), "ekez", first_id); + + // First proposal refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Finish up the second proposal. + let new_status = vote( + &mut app, + proposal_single.clone(), + "ekez", + second_id, + Vote::No, + ); + assert_eq!(Status::Rejected, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + close_proposal(&mut app, proposal_single, "ekez", second_id); + + // All deposits have been refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(20, balance.u128()); +} + +#[test] +fn test_set_version() { + 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: _, + proposal_single: _, + pre_propose: _, + _approver_core_addr: _, + proposal_single_approver: _, + pre_propose_approver, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + let info: ContractVersion = from_slice( + &app.wrap() + .query_wasm_raw(pre_propose_approver, "contract_info".as_bytes()) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!( + ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string() + }, + info + ) +} + +#[test] +fn test_permissions() { + 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, + proposal_single: _, + pre_propose, + _approver_core_addr: _, + proposal_single_approver: _, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, // no open proposal submission. + ); + + let err: PreProposeError = app + .execute_contract( + core_addr, + pre_propose.clone(), + &ExecuteMsg::ProposalCompletedHook { + proposal_id: 1, + new_status: Status::Closed, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotModule {}); + + // Non-members may not propose when open_propose_submission is + // disabled. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &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![], + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotMember {}); +} + +#[test] +fn test_approval_and_rejection_permissions() { + 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: _, + proposal_single: _, + pre_propose, + _approver_core_addr: _, + proposal_single_approver: _, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let pre_propose_id = make_pre_proposal( + &mut app, + pre_propose.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + + // Only approver can propose + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose.clone(), + &ExecuteMsg::Extension { + msg: ExecuteExt::Approve { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); + + // Only approver can propose + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &ExecuteMsg::Extension { + msg: ExecuteExt::Reject { id: pre_propose_id }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unauthorized {}); +} + +#[test] +fn test_propose_open_proposal_submission() { + 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: _, + proposal_single, + pre_propose, + _approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let _pre_propose_id = + make_pre_proposal(&mut app, pre_propose, "nonmember", &coins(10, "ujuno")); + + // Approver DAO votes to approves + let approver_prop_id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal(&mut app, proposal_single_approver, "ekez", approver_prop_id); + let id = get_latest_proposal_id(&app, proposal_single.clone()); + + // Member votes. + let new_status = vote(&mut app, proposal_single, "ekez", id, Vote::Yes); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_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, + proposal_single, + pre_propose, + _approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test(&mut app, None, false); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + open_proposal_submission: false + } + ); + + let _pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver DAO votes to approves + let approver_prop_id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal( + &mut app, + proposal_single_approver.clone(), + "ekez", + approver_prop_id, + ); + let id = get_latest_proposal_id(&app, proposal_single.clone()); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never, + }), + true, + ); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + open_proposal_submission: true, + } + ); + + // Old proposal should still have same deposit info. + let info = get_deposit_info(&app, pre_propose.clone(), id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: None, + proposer: Addr::unchecked("ekez"), + } + ); + + // New proposals should have the new deposit info. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let _new_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Approver DAO votes to approve prop + let approver_prop_id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal( + &mut app, + proposal_single_approver.clone(), + "ekez", + approver_prop_id, + ); + let new_id = get_latest_proposal_id(&app, proposal_single_approver); + + let info = get_deposit_info(&app, pre_propose.clone(), new_id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + proposer: Addr::unchecked("ekez"), + } + ); + + // Both proposals should be allowed to complete. + vote(&mut app, proposal_single.clone(), "ekez", id, Vote::Yes); + vote(&mut app, proposal_single.clone(), "ekez", new_id, Vote::Yes); + execute_proposal(&mut app, proposal_single.clone(), "ekez", id); + execute_proposal(&mut app, proposal_single.clone(), "ekez", new_id); + // Deposit should not have been refunded (never policy in use). + let balance = get_balance_native(&app, "ekez", "ujuno"); + 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); + assert_eq!(err, PreProposeError::NotDao {}); +} + +#[test] +fn test_withdraw() { + 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, + proposal_single, + pre_propose, + _approver_core_addr: _, + proposal_single_approver, + pre_propose_approver: _, + } = setup_default_test(&mut app, None, false); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + proposal_single.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NotDao {}); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NothingToWithdraw {}); + + let err = withdraw_should_fail(&mut app, pre_propose.clone(), core_addr.as_str(), None); + assert_eq!(err, PreProposeError::NoWithdrawalDenom {}); + + // Turn on native deposits. + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + // Withdraw with no specified denom - should fall back to the one + // in the config. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(10)); + + // Withdraw again, this time specifying a native denomination. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(20)); + + // Make a proposal with the native tokens to put some in the system. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let _native_pre_propose_id = + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &coins(10, "ujuno")); + + // Approver DAO votes to approve + let approver_prop_id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal( + &mut app, + proposal_single_approver.clone(), + "ekez", + approver_prop_id, + ); + let native_id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + + // Update the config to use a cw20 token. + let cw20_address = instantiate_cw20_base_default(&mut app); + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let _cw20_pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + // Approver DAO votes to approve + let approver_prop_id = get_latest_proposal_id(&app, proposal_single_approver.clone()); + approve_proposal(&mut app, proposal_single_approver, "ekez", approver_prop_id); + let cw20_id = get_latest_proposal_id(&app, proposal_single.clone()); + + // There is now a pending proposal and cw20 tokens in the + // pre-propose module that should be returned on that proposal's + // completion. To make things interesting, we withdraw those + // tokens which should cause the status change hook on the + // proposal's execution to fail as we don't have sufficent balance + // to return the deposit. + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_cw20(&app, &cw20_address, core_addr.as_str()); + assert_eq!(balance, Uint128::new(10)); + + // Proposal should still be executable! We just get removed from + // the proposal module's hook receiver list. + vote( + &mut app, + proposal_single.clone(), + "ekez", + cw20_id, + Vote::Yes, + ); + execute_proposal(&mut app, proposal_single.clone(), "ekez", cw20_id); + + // Make sure the proposal module has fallen back to anyone can + // propose becuase of our malfunction. + let proposal_creation_policy: ProposalCreationPolicy = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &cps::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + assert_eq!(proposal_creation_policy, ProposalCreationPolicy::Anyone {}); + + // Close out the native proposal and it's deposit as well. + vote( + &mut app, + proposal_single.clone(), + "ekez", + native_id, + Vote::No, + ); + close_proposal(&mut app, proposal_single.clone(), "ekez", native_id); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(30)); +} diff --git a/contracts/pre-propose/dao-pre-propose-multiple/.cargo/config b/contracts/pre-propose/dao-pre-propose-multiple/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-multiple/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/pre-propose/dao-pre-propose-multiple/Cargo.toml b/contracts/pre-propose/dao-pre-propose-multiple/Cargo.toml new file mode 100644 index 000000000..7352139b4 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-multiple/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "dao-pre-propose-multiple" +authors = ["ekez ", "Jake Hartnell ", "blue-note"] +description = "A DAO DAO pre-propose module for dao-proposal-multiple for native and cw20 deposits." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw2 = { workspace = true } +dao-pre-propose-base = { workspace = true } +dao-voting = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +cw-utils = { workspace = true } +cw4-group = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-proposal-multiple = { workspace = true } +dao-dao-core = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting = { workspace = true } +cw-denom = { workspace = true } +dao-interface = { workspace = true } +dao-testing = { workspace = true } +dao-proposal-hooks = { workspace = true } diff --git a/contracts/pre-propose/dao-pre-propose-multiple/README.md b/contracts/pre-propose/dao-pre-propose-multiple/README.md new file mode 100644 index 000000000..b599dd3a3 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-multiple/README.md @@ -0,0 +1,33 @@ +# Multiple choice proposal deposit contract + +This is a pre-propose module that manages proposal deposits for the +`dao-proposal-multiple` proposal module. + +It may accept either native ([bank +module](https://docs.cosmos.network/main/modules/bank/)), +[cw20](https://github.com/CosmWasm/cw-plus/tree/bc339368b1ee33c97c55a19d4cff983c7708ce36/packages/cw20) +tokens, or no tokens as a deposit. If a proposal deposit is enabled +the following refund strategies are avaliable: + +1. Never refund deposits. All deposits are sent to the DAO on proposal + completion. +2. Always refund deposits. Deposits are returned to the proposer on + proposal completion. +3. Only refund passed proposals. Deposits are only returned to the + proposer if the proposal passes. Otherwise, they are sent to the + DAO. + +This module may also be configured to only accept proposals from +members (addresses with voting power) of the DAO. + +Here is a flowchart showing the proposal creation process using this +module: + +![](https://bafkreibymt3n6avrpdeukwqplw366yyk5cgrrjtwszib2hk2updmyy7apa.ipfs.nftstorage.link/) + + +### Resources + +More about the [pre-propose design](https://github.com/DA0-DA0/dao-contracts/wiki/Pre-propose-module-design). + +More about [pre-propose modules](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#pre-propose-modules). diff --git a/contracts/pre-propose/dao-pre-propose-multiple/examples/schema.rs b/contracts/pre-propose/dao-pre-propose-multiple/examples/schema.rs new file mode 100644 index 000000000..e75470342 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-multiple/examples/schema.rs @@ -0,0 +1,12 @@ +use cosmwasm_schema::write_api; +use cosmwasm_std::Empty; +use dao_pre_propose_base::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use dao_pre_propose_multiple::ProposeMessage; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} 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 new file mode 100644 index 000000000..d0fb4ab83 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-multiple/schema/dao-pre-propose-multiple.json @@ -0,0 +1,1747 @@ +{ + "contract_name": "dao-pre-propose-multiple", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "extension", + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit requirements for this module. None if no deposit.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "extension": { + "description": "Extension for instantiation. The default implementation will do nothing with this data.", + "allOf": [ + { + "$ref": "#/definitions/Empty" + } + ] + }, + "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" + } + }, + "additionalProperties": false, + "definitions": { + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token address of the associated DAO's voting module. NOTE: in order to use the token address of the voting module the voting module must (1) use a cw20 token and (2) implement the `TokenContract {}` query type defined by `dao_dao_macros::token_query`. Failing to implement that and using this option will cause instantiation to fail.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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" + }, + "UncheckedDenom": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Creates a new proposal in the pre-propose module. MSG will be serialized and used as the proposal creation message.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/ProposeMessage" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the configuration of this module. This will completely override the existing configuration. This new configuration will only apply to proposals created after the config is updated. Only the DAO may execute this message.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "open_proposal_submission": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Withdraws funds inside of this contract to the message sender. The contracts entire balance for the specifed DENOM is withdrawn to the message sender. Only the DAO may call this method.\n\nThis is intended only as an escape hatch in the event of a critical bug in this contract or it's proposal module. Withdrawing funds will cause future attempts to return proposal deposits to fail their transactions as the contract will have insufficent balance to return them. In the case of `cw-proposal-single` this transaction failure will cause the module to remove the pre-propose module from its proposal hook receivers.\n\nMore likely than not, this should NEVER BE CALLED unless a bug in this contract or the proposal module it is associated with has caused it to stop receiving proposal hook messages, or if a critical security vulnerability has been found that allows an attacker to drain proposal deposits.", + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "properties": { + "denom": { + "description": "The denom to withdraw funds for. If no denom is specified, the denomination currently configured for proposal deposits will be used.\n\nYou may want to specify a denomination here if you are withdrawing funds that were previously accepted for proposal deposits but are not longer used due to an `UpdateConfig` message being executed on the contract.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension message. Contracts that extend this one should put their custom execute logic here. The default implementation will do nothing if this variant is executed.", + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds a proposal submitted hook. Fires when a new proposal is submitted to the pre-propose contract. Only the DAO may call this method.", + "type": "object", + "required": [ + "add_proposal_submitted_hook" + ], + "properties": { + "add_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a proposal submitted hook. Only the DAO may call this method.", + "type": "object", + "required": [ + "remove_proposal_submitted_hook" + ], + "properties": { + "remove_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Handles proposal hook fired by the associated proposal module when a proposal is completed (ie executed or rejected). By default, the base contract will return deposits proposals, when they are closed, when proposals are executed, or, if it is refunding failed.", + "type": "object", + "required": [ + "proposal_completed_hook" + ], + "properties": { + "proposal_completed_hook": { + "type": "object", + "required": [ + "new_status", + "proposal_id" + ], + "properties": { + "new_status": { + "$ref": "#/definitions/Status" + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "additionalProperties": false + } + ] + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token address of the associated DAO's voting module. NOTE: in order to use the token address of the voting module the voting module must (1) use a cw20 token and (2) implement the `TokenContract {}` query type defined by `dao_dao_macros::token_query`. Failing to implement that and using this option will cause instantiation to fail.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "MultipleChoiceOption": { + "description": "Unchecked multiple choice option", + "type": "object", + "required": [ + "description", + "msgs", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, + "MultipleChoiceOptions": { + "description": "Represents unchecked multiple choice options", + "type": "object", + "required": [ + "options" + ], + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/MultipleChoiceOption" + } + } + }, + "additionalProperties": false + }, + "ProposeMessage": { + "oneOf": [ + { + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "choices", + "description", + "title" + ], + "properties": { + "choices": { + "$ref": "#/definitions/MultipleChoiceOptions" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "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": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Gets the proposal module that this pre propose module is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "proposal_module" + ], + "properties": { + "proposal_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the DAO (dao-dao-core) module this contract is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "dao" + ], + "properties": { + "dao": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the module's configuration.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the deposit info for the proposal identified by PROPOSAL_ID.", + "type": "object", + "required": [ + "deposit_info" + ], + "properties": { + "deposit_info": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of proposal submitted hooks.", + "type": "object", + "required": [ + "proposal_submitted_hooks" + ], + "properties": { + "proposal_submitted_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension for queries. The default implementation will do nothing if queried for will return `Binary::default()`.", + "type": "object", + "required": [ + "query_extension" + ], + "properties": { + "query_extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Empty": { + "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" + } + } + }, + "migrate": null, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit required to create a proposal. If `None`, no deposit is required.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "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" + } + }, + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "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" + } + } + }, + "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" + }, + "deposit_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DepositInfoResponse", + "type": "object", + "required": [ + "proposer" + ], + "properties": { + "deposit_info": { + "description": "The deposit that has been paid for the specified proposal.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "description": "The address that created the proposal.", + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "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" + } + } + }, + "proposal_module": { + "$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" + }, + "proposal_submitted_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "query_extension": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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" + } + } +} diff --git a/contracts/pre-propose/dao-pre-propose-multiple/src/contract.rs b/contracts/pre-propose/dao-pre-propose-multiple/src/contract.rs new file mode 100644 index 000000000..cfe067683 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-multiple/src/contract.rs @@ -0,0 +1,115 @@ +use cosmwasm_schema::cw_serde; +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult}; +use cw2::set_contract_version; + +use dao_pre_propose_base::{ + error::PreProposeError, + msg::{ExecuteMsg as ExecuteBase, InstantiateMsg as InstantiateBase, QueryMsg as QueryBase}, + state::PreProposeContract, +}; +use dao_voting::multiple_choice::MultipleChoiceOptions; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-pre-propose-multiple"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cw_serde] +pub enum ProposeMessage { + Propose { + title: String, + description: String, + choices: MultipleChoiceOptions, + }, +} + +pub type InstantiateMsg = InstantiateBase; +pub type ExecuteMsg = ExecuteBase; +pub type QueryMsg = QueryBase; + +/// Internal version of the propose message that includes the +/// `proposer` field. The module will fill this in based on the sender +/// of the external message. +#[cw_serde] +enum ProposeMessageInternal { + Propose { + title: String, + description: String, + choices: MultipleChoiceOptions, + proposer: Option, + }, +} + +type PrePropose = PreProposeContract; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let resp = PrePropose::default().instantiate(deps.branch(), env, info, msg)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(resp) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + // We don't want to expose the `proposer` field on the propose + // message externally as that is to be set by this module. Here, + // we transform an external message which omits that field into an + // internal message which sets it. + type ExecuteInternal = ExecuteBase; + let internalized = match msg { + ExecuteMsg::Propose { + msg: + ProposeMessage::Propose { + title, + description, + choices, + }, + } => ExecuteInternal::Propose { + msg: ProposeMessageInternal::Propose { + proposer: Some(info.sender.to_string()), + title, + description, + choices, + }, + }, + ExecuteMsg::Extension { msg } => ExecuteInternal::Extension { msg }, + ExecuteMsg::Withdraw { denom } => ExecuteInternal::Withdraw { denom }, + ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + } => ExecuteInternal::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + ExecuteMsg::AddProposalSubmittedHook { address } => { + ExecuteInternal::AddProposalSubmittedHook { address } + } + ExecuteMsg::RemoveProposalSubmittedHook { address } => { + ExecuteInternal::RemoveProposalSubmittedHook { address } + } + ExecuteBase::ProposalCompletedHook { + proposal_id, + new_status, + } => ExecuteInternal::ProposalCompletedHook { + proposal_id, + new_status, + }, + }; + + PrePropose::default().execute(deps, env, info, internalized) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + PrePropose::default().query(deps, env, msg) +} diff --git a/contracts/pre-propose/dao-pre-propose-multiple/src/lib.rs b/contracts/pre-propose/dao-pre-propose-multiple/src/lib.rs new file mode 100644 index 000000000..16d6ba5ae --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-multiple/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; + +#[cfg(test)] +mod tests; + +pub use contract::{ExecuteMsg, InstantiateMsg, ProposeMessage, QueryMsg}; + +// Exporting these means that contracts interacting with this one don't +// need an explicit dependency on the base contract to read queries. +pub use dao_pre_propose_base::msg::DepositInfoResponse; +pub use dao_pre_propose_base::state::Config; diff --git a/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs b/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs new file mode 100644 index 000000000..e256a1f47 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs @@ -0,0 +1,1412 @@ +use cosmwasm_std::{coins, from_slice, to_binary, Addr, Coin, Decimal, Empty, Uint128}; +use cpm::query::ProposalResponse; +use cw2::ContractVersion; +use cw20::Cw20Coin; +use cw_denom::UncheckedDenom; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor}; +use cw_utils::Duration; +use dao_interface::state::ProposalModule; +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::{ + deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + multiple_choice::{ + CheckedMultipleChoiceOption, MultipleChoiceOption, MultipleChoiceOptionType, + MultipleChoiceOptions, MultipleChoiceVote, VotingStrategy, + }, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + status::Status, + threshold::PercentageThreshold, +}; + +use crate::contract::*; + +fn cw_dao_proposal_multiple_contract() -> Box> { + let contract = ContractWrapper::new( + cpm::contract::execute, + cpm::contract::instantiate, + cpm::contract::query, + ) + .with_reply(cpm::contract::reply); + Box::new(contract) +} + +fn cw_pre_propose_base_proposal_single() -> Box> { + let contract = ContractWrapper::new(execute, instantiate, query); + Box::new(contract) +} + +fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn get_default_proposal_module_instantiate( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> cpm::msg::InstantiateMsg { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + cpm::msg::InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info, + open_proposal_submission, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } +} + +fn instantiate_cw20_base_default(app: &mut App) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }], + mint: None, + marketing: None, + }; + app.instantiate_contract( + cw20_id, + Addr::unchecked("ekez"), + &cw20_instantiate, + &[], + "cw20-base", + None, + ) + .unwrap() +} + +struct DefaultTestSetup { + core_addr: Addr, + proposal_single: Addr, + pre_propose: Addr, +} +fn setup_default_test( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> DefaultTestSetup { + let cpm_id = app.store_code(cw_dao_proposal_multiple_contract()); + + let proposal_module_instantiate = + get_default_proposal_module_instantiate(app, deposit_info, open_proposal_submission); + + let core_addr = instantiate_with_cw4_groups_governance( + app, + cpm_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(proposal_modules.len(), 1); + let proposal_single = proposal_modules.into_iter().next().unwrap().address; + let proposal_creation_policy = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &cpm::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Module { addr } => addr, + _ => panic!("expected a module for the proposal creation policy"), + }; + + // Make sure things were set up correctly. + assert_eq!( + proposal_single, + get_proposal_module(app, pre_propose.clone()) + ); + assert_eq!(core_addr, get_dao(app, pre_propose.clone())); + + DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } +} + +fn make_proposal( + app: &mut App, + pre_propose: Addr, + proposal_module: Addr, + proposer: &str, + funds: &[Coin], +) -> u64 { + app.execute_contract( + Addr::unchecked(proposer), + pre_propose, + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ], + }, + }, + }, + funds, + ) + .unwrap(); + + let id: u64 = app + .wrap() + .query_wasm_smart(&proposal_module, &cpm::msg::QueryMsg::NextProposalId {}) + .unwrap(); + let id = id - 1; + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart( + proposal_module, + &cpm::msg::QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(); + + assert_eq!(proposal.proposal.proposer, Addr::unchecked(proposer)); + assert_eq!(proposal.proposal.title, "title".to_string()); + assert_eq!(proposal.proposal.description, "description".to_string()); + assert_eq!( + proposal.proposal.choices, + vec![ + CheckedMultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + option_type: MultipleChoiceOptionType::Standard, + vote_count: Uint128::zero(), + index: 0, + title: "title".to_string(), + }, + CheckedMultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + option_type: MultipleChoiceOptionType::Standard, + vote_count: Uint128::zero(), + index: 1, + title: "title".to_string(), + }, + CheckedMultipleChoiceOption { + description: "None of the above".to_string(), + msgs: vec![], + option_type: MultipleChoiceOptionType::None, + vote_count: Uint128::zero(), + index: 2, + title: "None of the above".to_string(), + }, + ] + ); + + id +} + +fn mint_natives(app: &mut App, receiver: &str, coins: Vec) { + // Mint some ekez tokens for ekez so we can pay the deposit. + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: receiver.to_string(), + amount: coins, + })) + .unwrap(); +} + +fn increase_allowance(app: &mut App, sender: &str, receiver: &Addr, cw20: Addr, amount: Uint128) { + app.execute_contract( + Addr::unchecked(sender), + cw20, + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: receiver.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); +} + +fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn get_balance_native(app: &App, who: &str, denom: &str) -> Uint128 { + let res = app.wrap().query_balance(who, denom).unwrap(); + res.amount +} + +fn vote( + app: &mut App, + module: Addr, + sender: &str, + id: u64, + position: MultipleChoiceVote, +) -> Status { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &cpm::msg::ExecuteMsg::Vote { + proposal_id: id, + vote: position, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart(module, &cpm::msg::QueryMsg::Proposal { proposal_id: id }) + .unwrap(); + + proposal.proposal.status +} + +fn get_config(app: &App, module: Addr) -> Config { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Config {}) + .unwrap() +} + +fn get_dao(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Dao {}) + .unwrap() +} + +fn get_proposal_module(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::ProposalModule {}) + .unwrap() +} + +fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::DepositInfo { proposal_id: id }) + .unwrap() +} + +fn update_config( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + open_proposal_submission: bool, +) -> Config { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + &[], + ) + .unwrap(); + + get_config(app, module) +} + +fn update_config_should_fail( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + open_proposal_submission: bool, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn withdraw(app: &mut App, module: Addr, sender: &str, denom: Option) { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap(); +} + +fn withdraw_should_fail( + app: &mut App, + module: Addr, + sender: &str, + denom: Option, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn close_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &cpm::msg::ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap(); +} + +fn execute_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &cpm::msg::ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); +} + +enum EndStatus { + Passed, + Failed, +} +enum RefundReceiver { + Proposer, + Dao, +} + +fn test_native_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, +) { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let id = make_proposal( + &mut app, + pre_propose, + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + + // Make sure it went away. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(balance, Uint128::zero()); + + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => ( + MultipleChoiceVote { option_id: 0 }, + Status::Passed, + execute_proposal, + ), + EndStatus::Failed => ( + MultipleChoiceVote { option_id: 2 }, + Status::Rejected, + close_proposal, + ), + }; + let new_status = vote(&mut app, proposal_single.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_single, "ekez", id); + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_native(&app, "ekez", "ujuno"); + let dao_balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +fn test_cw20_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, +) { + let mut app = App::default(); + + let cw20_address = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &[], + ); + + // Make sure it went await. + let balance = get_balance_cw20(&app, cw20_address.clone(), "ekez"); + assert_eq!(balance, Uint128::zero()); + + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => ( + MultipleChoiceVote { option_id: 0 }, + Status::Passed, + execute_proposal, + ), + EndStatus::Failed => ( + MultipleChoiceVote { option_id: 2 }, + Status::Rejected, + close_proposal, + ), + }; + let new_status = vote(&mut app, proposal_single.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_single, "ekez", id); + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_cw20(&app, &cw20_address, "ekez"); + let dao_balance = get_balance_cw20(&app, &cw20_address, core_addr); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +#[test] +fn test_native_failed_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ) +} +#[test] +fn test_cw20_failed_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ) +} + +#[test] +fn test_native_passed_always_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ) +} +#[test] +fn test_cw20_passed_always_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ) +} + +#[test] +fn test_native_passed_never_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ) +} +#[test] +fn test_cw20_passed_never_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ) +} + +#[test] +fn test_native_failed_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ) +} +#[test] +fn test_cw20_failed_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ) +} + +#[test] +fn test_native_passed_passed_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ) +} +#[test] +fn test_cw20_passed_passed_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ) +} + +#[test] +fn test_native_failed_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ) +} +#[test] +fn test_cw20_failed_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ) +} + +// See: +#[test] +fn test_multiple_open_proposals() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + let first_id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + let second_id = make_proposal( + &mut app, + pre_propose, + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Finish up the first proposal. + let new_status = vote( + &mut app, + proposal_single.clone(), + "ekez", + first_id, + MultipleChoiceVote { option_id: 0 }, + ); + assert_eq!(Status::Passed, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + execute_proposal(&mut app, proposal_single.clone(), "ekez", first_id); + + // First proposal refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Finish up the second proposal. + let new_status = vote( + &mut app, + proposal_single.clone(), + "ekez", + second_id, + MultipleChoiceVote { option_id: 2 }, + ); + assert_eq!(Status::Rejected, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + close_proposal(&mut app, proposal_single, "ekez", second_id); + + // All deposits have been refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(20, balance.u128()); +} + +#[test] +fn test_set_version() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + let info: ContractVersion = from_slice( + &app.wrap() + .query_wasm_raw(pre_propose, "contract_info".as_bytes()) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!( + ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string() + }, + info + ) +} + +#[test] +fn test_permissions() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, // no open proposal submission. + ); + + let err: PreProposeError = app + .execute_contract( + core_addr, + pre_propose.clone(), + &ExecuteMsg::ProposalCompletedHook { + proposal_id: 1, + new_status: Status::Closed, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotModule {}); + + // Non-members may not propose when open_propose_submission is + // disabled. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &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(), + }], + }, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotMember {}) +} + +#[test] +fn test_propose_open_proposal_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let id = make_proposal( + &mut app, + pre_propose, + proposal_single.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + // Member votes. + let new_status = vote( + &mut app, + proposal_single, + "ekez", + id, + MultipleChoiceVote { option_id: 0 }, + ); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_no_deposit_required_open_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, None, true, // yes, open proposal submission. + ); + + // Non-member proposes. + let id = make_proposal( + &mut app, + pre_propose, + proposal_single.clone(), + "nonmember", + &[], + ); + // Member votes. + let new_status = vote( + &mut app, + proposal_single, + "ekez", + id, + MultipleChoiceVote { option_id: 0 }, + ); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_no_deposit_required_members_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, None, false, // no open proposal submission. + ); + + // Non-member proposes and this fails. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + 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(), + }], + }, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotMember {}); + + let id = make_proposal(&mut app, pre_propose, proposal_single.clone(), "ekez", &[]); + let new_status = vote( + &mut app, + proposal_single, + "ekez", + id, + MultipleChoiceVote { option_id: 0 }, + ); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_execute_extension_does_nothing() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, None, false, // no open proposal submission. + ); + + let res = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose, + &ExecuteMsg::Extension { + msg: Empty::default(), + }, + &[], + ) + .unwrap(); + + // There should be one event which is the invocation of the contract. + assert_eq!(res.events.len(), 1); + assert_eq!(res.events[0].ty, "execute".to_string()); + assert_eq!(res.events[0].attributes.len(), 1); + assert_eq!( + res.events[0].attributes[0].key, + "_contract_addr".to_string() + ) +} + +#[test] +#[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] +fn test_instantiate_with_zero_native_deposit() { + let mut app = App::default(); + + let cpm_id = app.store_code(cw_dao_proposal_multiple_contract()); + + let proposal_module_instantiate = { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + cpm::msg::InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::zero(), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } + }; + + // Should panic. + instantiate_with_cw4_groups_governance( + &mut app, + cpm_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); +} + +#[test] +#[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] +fn test_instantiate_with_zero_cw20_deposit() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20_base_default(&mut app); + + let cpm_id = app.store_code(cw_dao_proposal_multiple_contract()); + + let proposal_module_instantiate = { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + cpm::msg::InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_addr.into_string()), + }, + amount: Uint128::zero(), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } + }; + + // Should panic. + instantiate_with_cw4_groups_governance( + &mut app, + cpm_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); +} + +#[test] +fn test_update_config() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + open_proposal_submission: false + } + ); + + let id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &[], + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never, + }), + true, + ); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + open_proposal_submission: true, + } + ); + + // Old proposal should still have same deposit info. + let info = get_deposit_info(&app, pre_propose.clone(), id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: None, + proposer: Addr::unchecked("ekez"), + } + ); + + // New proposals should have the new deposit info. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let new_id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + let info = get_deposit_info(&app, pre_propose.clone(), new_id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + proposer: Addr::unchecked("ekez"), + } + ); + + // Both proposals should be allowed to complete. + vote( + &mut app, + proposal_single.clone(), + "ekez", + id, + MultipleChoiceVote { option_id: 0 }, + ); + vote( + &mut app, + proposal_single.clone(), + "ekez", + new_id, + MultipleChoiceVote { option_id: 0 }, + ); + execute_proposal(&mut app, proposal_single.clone(), "ekez", id); + execute_proposal(&mut app, proposal_single.clone(), "ekez", new_id); + // Deposit should not have been refunded (never policy in use). + let balance = get_balance_native(&app, "ekez", "ujuno"); + 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); + assert_eq!(err, PreProposeError::NotDao {}); +} + +#[test] +fn test_withdraw() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + proposal_single.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NotDao {}); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NothingToWithdraw {}); + + let err = withdraw_should_fail(&mut app, pre_propose.clone(), core_addr.as_str(), None); + assert_eq!(err, PreProposeError::NoWithdrawalDenom {}); + + // Turn on native deposits. + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + // Withdraw with no specified denom - should fall back to the one + // in the config. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(10)); + + // Withdraw again, this time specifying a native denomination. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(20)); + + // Make a proposal with the native tokens to put some in the system. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let native_id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + + // Update the config to use a cw20 token. + let cw20_address = instantiate_cw20_base_default(&mut app); + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let cw20_id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &[], + ); + + // There is now a pending proposal and cw20 tokens in the + // pre-propose module that should be returned on that proposal's + // completion. Execute an early withdraw and make sure things play + // out correctly. + + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_cw20(&app, &cw20_address, core_addr.as_str()); + assert_eq!(balance, Uint128::new(10)); + + // Proposal should still be executable! We just get removed from + // the proposal module's hook receiver list. + vote( + &mut app, + proposal_single.clone(), + "ekez", + cw20_id, + MultipleChoiceVote { option_id: 0 }, + ); + execute_proposal(&mut app, proposal_single.clone(), "ekez", cw20_id); + + // Make sure the proposal module has fallen back to anyone can + // propose becuase of our malfunction. + let proposal_creation_policy: ProposalCreationPolicy = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &cpm::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + assert_eq!(proposal_creation_policy, ProposalCreationPolicy::Anyone {}); + + // Close out the native proposal and it's deposit as well. + vote( + &mut app, + proposal_single.clone(), + "ekez", + native_id, + MultipleChoiceVote { option_id: 2 }, + ); + close_proposal(&mut app, proposal_single.clone(), "ekez", native_id); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(30)); +} diff --git a/contracts/pre-propose/dao-pre-propose-single/.cargo/config b/contracts/pre-propose/dao-pre-propose-single/.cargo/config new file mode 100644 index 000000000..ab407a024 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-single/.cargo/config @@ -0,0 +1,4 @@ +[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-single/Cargo.toml b/contracts/pre-propose/dao-pre-propose-single/Cargo.toml new file mode 100644 index 000000000..c7d1dd39a --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-single/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "dao-pre-propose-single" +authors = ["ekez "] +description = "A DAO DAO pre-propose module for dao-proposal-single for native and cw20 deposits." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw2 = { workspace = true } +dao-pre-propose-base = { workspace = true } +dao-voting = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +cw-utils = { workspace = true } +cw4-group = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-dao-core = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting = { workspace = true } +cw-denom = { workspace = true } +dao-interface = { workspace = true } +dao-testing = { workspace = true } +dao-proposal-hooks = { workspace = true } +dao-proposal-single = { workspace = true } +cw-hooks = { workspace = true } diff --git a/contracts/pre-propose/dao-pre-propose-single/README.md b/contracts/pre-propose/dao-pre-propose-single/README.md new file mode 100644 index 000000000..5028764b5 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-single/README.md @@ -0,0 +1,32 @@ +# Single choice proposal deposit contract + +This is a pre-propose module that manages proposal deposits for the +`cwd-proposal-single` proposal module. + +It may accept either native ([bank +module](https://docs.cosmos.network/main/modules/bank/)), +[cw20](https://github.com/CosmWasm/cw-plus/tree/bc339368b1ee33c97c55a19d4cff983c7708ce36/packages/cw20) +tokens, or no tokens as a deposit. If a proposal deposit is enabled +the following refund strategies are avaliable: + +1. Never refund deposits. All deposits are sent to the DAO on proposal + completion. +2. Always refund deposits. Deposits are returned to the proposer on + proposal completion. +3. Only refund passed proposals. Deposits are only returned to the + proposer if the proposal passes. Otherwise, they are sent to the + DAO. + +This module may also be configured to only accept proposals from +members (addresses with voting power) of the DAO. + +Here is a flowchart showing the proposal creation process using this +module: + +![](https://bafkreig42cxswefi2ks7vhrwyvkcnumbnwdk7ov643yaafm7loi6vh2gja.ipfs.nftstorage.link) + +### Resources + +More about the [pre-propose design](https://github.com/DA0-DA0/dao-contracts/wiki/Pre-propose-module-design). + +More about [pre-propose modules](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#pre-propose-modules). diff --git a/contracts/pre-propose/dao-pre-propose-single/examples/schema.rs b/contracts/pre-propose/dao-pre-propose-single/examples/schema.rs new file mode 100644 index 000000000..8841e17bd --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-single/examples/schema.rs @@ -0,0 +1,12 @@ +use cosmwasm_schema::write_api; +use cosmwasm_std::Empty; +use dao_pre_propose_base::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use dao_pre_propose_single::ProposeMessage; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} 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 new file mode 100644 index 000000000..9cf71147a --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-single/schema/dao-pre-propose-single.json @@ -0,0 +1,1711 @@ +{ + "contract_name": "dao-pre-propose-single", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "extension", + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit requirements for this module. None if no deposit.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "extension": { + "description": "Extension for instantiation. The default implementation will do nothing with this data.", + "allOf": [ + { + "$ref": "#/definitions/Empty" + } + ] + }, + "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" + } + }, + "additionalProperties": false, + "definitions": { + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token address of the associated DAO's voting module. NOTE: in order to use the token address of the voting module the voting module must (1) use a cw20 token and (2) implement the `TokenContract {}` query type defined by `dao_dao_macros::token_query`. Failing to implement that and using this option will cause instantiation to fail.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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" + }, + "UncheckedDenom": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Creates a new proposal in the pre-propose module. MSG will be serialized and used as the proposal creation message.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/ProposeMessage" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the configuration of this module. This will completely override the existing configuration. This new configuration will only apply to proposals created after the config is updated. Only the DAO may execute this message.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "open_proposal_submission": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Withdraws funds inside of this contract to the message sender. The contracts entire balance for the specifed DENOM is withdrawn to the message sender. Only the DAO may call this method.\n\nThis is intended only as an escape hatch in the event of a critical bug in this contract or it's proposal module. Withdrawing funds will cause future attempts to return proposal deposits to fail their transactions as the contract will have insufficent balance to return them. In the case of `cw-proposal-single` this transaction failure will cause the module to remove the pre-propose module from its proposal hook receivers.\n\nMore likely than not, this should NEVER BE CALLED unless a bug in this contract or the proposal module it is associated with has caused it to stop receiving proposal hook messages, or if a critical security vulnerability has been found that allows an attacker to drain proposal deposits.", + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "properties": { + "denom": { + "description": "The denom to withdraw funds for. If no denom is specified, the denomination currently configured for proposal deposits will be used.\n\nYou may want to specify a denomination here if you are withdrawing funds that were previously accepted for proposal deposits but are not longer used due to an `UpdateConfig` message being executed on the contract.", + "anyOf": [ + { + "$ref": "#/definitions/UncheckedDenom" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension message. Contracts that extend this one should put their custom execute logic here. The default implementation will do nothing if this variant is executed.", + "type": "object", + "required": [ + "extension" + ], + "properties": { + "extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds a proposal submitted hook. Fires when a new proposal is submitted to the pre-propose contract. Only the DAO may call this method.", + "type": "object", + "required": [ + "add_proposal_submitted_hook" + ], + "properties": { + "add_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a proposal submitted hook. Only the DAO may call this method.", + "type": "object", + "required": [ + "remove_proposal_submitted_hook" + ], + "properties": { + "remove_proposal_submitted_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Handles proposal hook fired by the associated proposal module when a proposal is completed (ie executed or rejected). By default, the base contract will return deposits proposals, when they are closed, when proposals are executed, or, if it is refunding failed.", + "type": "object", + "required": [ + "proposal_completed_hook" + ], + "properties": { + "proposal_completed_hook": { + "type": "object", + "required": [ + "new_status", + "proposal_id" + ], + "properties": { + "new_status": { + "$ref": "#/definitions/Status" + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "additionalProperties": false + } + ] + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "DepositToken": { + "description": "Information about the token to use for proposal deposits.", + "oneOf": [ + { + "description": "Use a specific token address as the deposit token.", + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Use the token address of the associated DAO's voting module. NOTE: in order to use the token address of the voting module the voting module must (1) use a cw20 token and (2) implement the `TokenContract {}` query type defined by `dao_dao_macros::token_query`. Failing to implement that and using this option will cause instantiation to fail.", + "type": "object", + "required": [ + "voting_module_token" + ], + "properties": { + "voting_module_token": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "ProposeMessage": { + "oneOf": [ + { + "description": "The propose message used to make a proposal to this module. Note that this is identical to the propose message used by dao-proposal-single, except that it omits the `proposer` field which it fills in for the sender.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "description", + "msgs", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "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": { + "description": "A denom that has not been checked to confirm it points to a valid asset.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "UncheckedDepositInfo": { + "description": "Information about the deposit required to create a proposal.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. Must be a positive, non-zero number.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositToken" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding deposits on proposal completion.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Gets the proposal module that this pre propose module is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "proposal_module" + ], + "properties": { + "proposal_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the DAO (dao-dao-core) module this contract is associated with. Returns `Addr`.", + "type": "object", + "required": [ + "dao" + ], + "properties": { + "dao": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the module's configuration.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the deposit info for the proposal identified by PROPOSAL_ID.", + "type": "object", + "required": [ + "deposit_info" + ], + "properties": { + "deposit_info": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns list of proposal submitted hooks.", + "type": "object", + "required": [ + "proposal_submitted_hooks" + ], + "properties": { + "proposal_submitted_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Extension for queries. The default implementation will do nothing if queried for will return `Binary::default()`.", + "type": "object", + "required": [ + "query_extension" + ], + "properties": { + "query_extension": { + "type": "object", + "required": [ + "msg" + ], + "properties": { + "msg": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Empty": { + "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" + } + } + }, + "migrate": null, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "open_proposal_submission" + ], + "properties": { + "deposit_info": { + "description": "Information about the deposit required to create a proposal. If `None`, no deposit is required.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "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" + } + }, + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "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" + } + } + }, + "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" + }, + "deposit_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DepositInfoResponse", + "type": "object", + "required": [ + "proposer" + ], + "properties": { + "deposit_info": { + "description": "The deposit that has been paid for the specified proposal.", + "anyOf": [ + { + "$ref": "#/definitions/CheckedDepositInfo" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "description": "The address that created the proposal.", + "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" + }, + "CheckedDenom": { + "description": "A denom that has been checked to point to a valid asset. This enum should never be constructed literally and should always be built by calling `into_checked` on an `UncheckedDenom` instance.", + "oneOf": [ + { + "description": "A native (bank module) asset.", + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "A cw20 asset.", + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "CheckedDepositInfo": { + "description": "Counterpart to the `DepositInfo` struct which has been processed. This type should never be constructed literally and should always by built by calling `into_checked` on a `DepositInfo` instance.", + "type": "object", + "required": [ + "amount", + "denom", + "refund_policy" + ], + "properties": { + "amount": { + "description": "The number of tokens that must be deposited to create a proposal. This is validated to be non-zero if this struct is constructed by converted via the `into_checked` method on `DepositInfo`.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "denom": { + "description": "The address of the cw20 token to be used for proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/CheckedDenom" + } + ] + }, + "refund_policy": { + "description": "The policy used for refunding proposal deposits.", + "allOf": [ + { + "$ref": "#/definitions/DepositRefundPolicy" + } + ] + } + }, + "additionalProperties": false + }, + "DepositRefundPolicy": { + "oneOf": [ + { + "description": "Deposits should always be refunded.", + "type": "string", + "enum": [ + "always" + ] + }, + { + "description": "Deposits should only be refunded for passed proposals.", + "type": "string", + "enum": [ + "only_passed" + ] + }, + { + "description": "Deposits should never be refunded.", + "type": "string", + "enum": [ + "never" + ] + } + ] + }, + "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" + } + } + }, + "proposal_module": { + "$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" + }, + "proposal_submitted_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "query_extension": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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" + } + } +} diff --git a/contracts/pre-propose/dao-pre-propose-single/src/contract.rs b/contracts/pre-propose/dao-pre-propose-single/src/contract.rs new file mode 100644 index 000000000..66a1e0555 --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-single/src/contract.rs @@ -0,0 +1,117 @@ +use cosmwasm_schema::cw_serde; +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult, +}; +use cw2::set_contract_version; + +use dao_pre_propose_base::{ + error::PreProposeError, + msg::{ExecuteMsg as ExecuteBase, InstantiateMsg as InstantiateBase, QueryMsg as QueryBase}, + state::PreProposeContract, +}; +use dao_voting::proposal::SingleChoiceProposeMsg as ProposeMsg; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-pre-propose-single"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cw_serde] +pub enum ProposeMessage { + /// The propose message used to make a proposal to this + /// module. Note that this is identical to the propose message + /// used by dao-proposal-single, except that it omits the + /// `proposer` field which it fills in for the sender. + Propose { + title: String, + description: String, + msgs: Vec>, + }, +} + +pub type InstantiateMsg = InstantiateBase; +pub type ExecuteMsg = ExecuteBase; +pub type QueryMsg = QueryBase; + +/// Internal version of the propose message that includes the +/// `proposer` field. The module will fill this in based on the sender +/// of the external message. +#[cw_serde] +enum ProposeMessageInternal { + Propose(ProposeMsg), +} + +type PrePropose = PreProposeContract; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let resp = PrePropose::default().instantiate(deps.branch(), env, info, msg)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(resp) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + // We don't want to expose the `proposer` field on the propose + // message externally as that is to be set by this module. Here, + // we transform an external message which omits that field into an + // internal message which sets it. + type ExecuteInternal = ExecuteBase; + let internalized = match msg { + ExecuteMsg::Propose { + msg: + ProposeMessage::Propose { + title, + description, + msgs, + }, + } => ExecuteInternal::Propose { + msg: ProposeMessageInternal::Propose(ProposeMsg { + // Fill in proposer based on message sender. + proposer: Some(info.sender.to_string()), + title, + description, + msgs, + }), + }, + ExecuteMsg::Extension { msg } => ExecuteInternal::Extension { msg }, + ExecuteMsg::Withdraw { denom } => ExecuteInternal::Withdraw { denom }, + ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + } => ExecuteInternal::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + ExecuteMsg::AddProposalSubmittedHook { address } => { + ExecuteInternal::AddProposalSubmittedHook { address } + } + ExecuteMsg::RemoveProposalSubmittedHook { address } => { + ExecuteInternal::RemoveProposalSubmittedHook { address } + } + ExecuteMsg::ProposalCompletedHook { + proposal_id, + new_status, + } => ExecuteInternal::ProposalCompletedHook { + proposal_id, + new_status, + }, + }; + + PrePropose::default().execute(deps, env, info, internalized) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + PrePropose::default().query(deps, env, msg) +} diff --git a/contracts/pre-propose/dao-pre-propose-single/src/lib.rs b/contracts/pre-propose/dao-pre-propose-single/src/lib.rs new file mode 100644 index 000000000..16d6ba5ae --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-single/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; + +#[cfg(test)] +mod tests; + +pub use contract::{ExecuteMsg, InstantiateMsg, ProposeMessage, QueryMsg}; + +// Exporting these means that contracts interacting with this one don't +// need an explicit dependency on the base contract to read queries. +pub use dao_pre_propose_base::msg::DepositInfoResponse; +pub use dao_pre_propose_base::state::Config; diff --git a/contracts/pre-propose/dao-pre-propose-single/src/tests.rs b/contracts/pre-propose/dao-pre-propose-single/src/tests.rs new file mode 100644 index 000000000..6a8b48e9f --- /dev/null +++ b/contracts/pre-propose/dao-pre-propose-single/src/tests.rs @@ -0,0 +1,1355 @@ +use cosmwasm_std::{coins, from_slice, to_binary, Addr, Coin, Empty, Uint128}; +use cps::query::ProposalResponse; +use cw2::ContractVersion; +use cw20::Cw20Coin; +use cw_denom::UncheckedDenom; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor}; +use cw_utils::Duration; +use dao_interface::state::ProposalModule; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_pre_propose_base::{error::PreProposeError, msg::DepositInfoResponse, state::Config}; +use dao_proposal_single as cps; +use dao_testing::helpers::instantiate_with_cw4_groups_governance; +use dao_voting::{ + deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + status::Status, + threshold::{PercentageThreshold, Threshold}, + voting::Vote, +}; + +use crate::contract::*; + +fn cw_dao_proposal_single_contract() -> Box> { + let contract = ContractWrapper::new( + cps::contract::execute, + cps::contract::instantiate, + cps::contract::query, + ) + .with_migrate(cps::contract::migrate) + .with_reply(cps::contract::reply); + Box::new(contract) +} + +fn cw_pre_propose_base_proposal_single() -> Box> { + let contract = ContractWrapper::new(execute, instantiate, query); + Box::new(contract) +} + +fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn get_default_proposal_module_instantiate( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> cps::msg::InstantiateMsg { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + cps::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils::Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info, + open_proposal_submission, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } +} + +fn instantiate_cw20_base_default(app: &mut App) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }], + mint: None, + marketing: None, + }; + app.instantiate_contract( + cw20_id, + Addr::unchecked("ekez"), + &cw20_instantiate, + &[], + "cw20-base", + None, + ) + .unwrap() +} + +struct DefaultTestSetup { + core_addr: Addr, + proposal_single: Addr, + pre_propose: Addr, +} +fn setup_default_test( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> DefaultTestSetup { + let cps_id = app.store_code(cw_dao_proposal_single_contract()); + + let proposal_module_instantiate = + get_default_proposal_module_instantiate(app, deposit_info, open_proposal_submission); + + let core_addr = instantiate_with_cw4_groups_governance( + app, + cps_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); + let proposal_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(proposal_modules.len(), 1); + let proposal_single = proposal_modules.into_iter().next().unwrap().address; + let proposal_creation_policy = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &cps::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Module { addr } => addr, + _ => panic!("expected a module for the proposal creation policy"), + }; + + // Make sure things were set up correctly. + assert_eq!( + proposal_single, + get_proposal_module(app, pre_propose.clone()) + ); + assert_eq!(core_addr, get_dao(app, pre_propose.clone())); + + DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } +} + +fn make_proposal( + app: &mut App, + pre_propose: Addr, + proposal_module: Addr, + proposer: &str, + funds: &[Coin], +) -> u64 { + app.execute_contract( + Addr::unchecked(proposer), + pre_propose, + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + }, + }, + funds, + ) + .unwrap(); + + let id: u64 = app + .wrap() + .query_wasm_smart(&proposal_module, &cps::msg::QueryMsg::NextProposalId {}) + .unwrap(); + let id = id - 1; + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart( + proposal_module, + &cps::msg::QueryMsg::Proposal { proposal_id: id }, + ) + .unwrap(); + + assert_eq!(proposal.proposal.proposer, Addr::unchecked(proposer)); + assert_eq!(proposal.proposal.title, "title".to_string()); + assert_eq!(proposal.proposal.description, "description".to_string()); + assert_eq!(proposal.proposal.msgs, vec![]); + + id +} + +fn mint_natives(app: &mut App, receiver: &str, coins: Vec) { + // Mint some ekez tokens for ekez so we can pay the deposit. + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: receiver.to_string(), + amount: coins, + })) + .unwrap(); +} + +fn increase_allowance(app: &mut App, sender: &str, receiver: &Addr, cw20: Addr, amount: Uint128) { + app.execute_contract( + Addr::unchecked(sender), + cw20, + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: receiver.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); +} + +fn add_hook(app: &mut App, sender: &str, module: &Addr, hook: &str) { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &ExecuteMsg::AddProposalSubmittedHook { + address: hook.to_string(), + }, + &[], + ) + .unwrap(); +} + +fn remove_hook(app: &mut App, sender: &str, module: &Addr, hook: &str) { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &ExecuteMsg::RemoveProposalSubmittedHook { + address: hook.to_string(), + }, + &[], + ) + .unwrap(); +} + +fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn get_balance_native(app: &App, who: &str, denom: &str) -> Uint128 { + let res = app.wrap().query_balance(who, denom).unwrap(); + res.amount +} + +fn vote(app: &mut App, module: Addr, sender: &str, id: u64, position: Vote) -> Status { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &cps::msg::ExecuteMsg::Vote { + rationale: None, + proposal_id: id, + vote: position, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart(module, &cps::msg::QueryMsg::Proposal { proposal_id: id }) + .unwrap(); + + proposal.proposal.status +} + +fn get_config(app: &App, module: Addr) -> Config { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Config {}) + .unwrap() +} + +fn get_dao(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::Dao {}) + .unwrap() +} + +fn query_hooks(app: &App, module: Addr) -> cw_hooks::HooksResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::ProposalSubmittedHooks {}) + .unwrap() +} + +fn get_proposal_module(app: &App, module: Addr) -> Addr { + app.wrap() + .query_wasm_smart(module, &QueryMsg::ProposalModule {}) + .unwrap() +} + +fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { + app.wrap() + .query_wasm_smart(module, &QueryMsg::DepositInfo { proposal_id: id }) + .unwrap() +} + +fn update_config( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + open_proposal_submission: bool, +) -> Config { + app.execute_contract( + Addr::unchecked(sender), + module.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + &[], + ) + .unwrap(); + + get_config(app, module) +} + +fn update_config_should_fail( + app: &mut App, + module: Addr, + sender: &str, + deposit_info: Option, + open_proposal_submission: bool, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::UpdateConfig { + deposit_info, + open_proposal_submission, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn withdraw(app: &mut App, module: Addr, sender: &str, denom: Option) { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap(); +} + +fn withdraw_should_fail( + app: &mut App, + module: Addr, + sender: &str, + denom: Option, +) -> PreProposeError { + app.execute_contract( + Addr::unchecked(sender), + module, + &ExecuteMsg::Withdraw { denom }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn close_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &cps::msg::ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap(); +} + +fn execute_proposal(app: &mut App, module: Addr, sender: &str, proposal_id: u64) { + app.execute_contract( + Addr::unchecked(sender), + module, + &cps::msg::ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); +} + +enum EndStatus { + Passed, + Failed, +} +enum RefundReceiver { + Proposer, + Dao, +} + +fn test_native_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, +) { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let id = make_proposal( + &mut app, + pre_propose, + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + + // Make sure it went away. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(balance, Uint128::zero()); + + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => (Vote::Yes, Status::Passed, execute_proposal), + EndStatus::Failed => (Vote::No, Status::Rejected, close_proposal), + }; + let new_status = vote(&mut app, proposal_single.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_single, "ekez", id); + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_native(&app, "ekez", "ujuno"); + let dao_balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +fn test_cw20_permutation( + end_status: EndStatus, + refund_policy: DepositRefundPolicy, + receiver: RefundReceiver, +) { + let mut app = App::default(); + + let cw20_address = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &[], + ); + + // Make sure it went await. + let balance = get_balance_cw20(&app, cw20_address.clone(), "ekez"); + assert_eq!(balance, Uint128::zero()); + + #[allow(clippy::type_complexity)] + let (position, expected_status, trigger_refund): ( + _, + _, + fn(&mut App, Addr, &str, u64) -> (), + ) = match end_status { + EndStatus::Passed => (Vote::Yes, Status::Passed, execute_proposal), + EndStatus::Failed => (Vote::No, Status::Rejected, close_proposal), + }; + let new_status = vote(&mut app, proposal_single.clone(), "ekez", id, position); + assert_eq!(new_status, expected_status); + + // Close or execute the proposal to trigger a refund. + trigger_refund(&mut app, proposal_single, "ekez", id); + + let (dao_expected, proposer_expected) = match receiver { + RefundReceiver::Proposer => (0, 10), + RefundReceiver::Dao => (10, 0), + }; + + let proposer_balance = get_balance_cw20(&app, &cw20_address, "ekez"); + let dao_balance = get_balance_cw20(&app, &cw20_address, core_addr); + assert_eq!(proposer_expected, proposer_balance.u128()); + assert_eq!(dao_expected, dao_balance.u128()) +} + +#[test] +fn test_native_failed_always_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ) +} +#[test] +fn test_cw20_failed_always_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ) +} + +#[test] +fn test_native_passed_always_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ) +} + +#[test] +fn test_cw20_passed_always_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Always, + RefundReceiver::Proposer, + ) +} + +#[test] +fn test_native_passed_never_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ) +} +#[test] +fn test_cw20_passed_never_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ) +} + +#[test] +fn test_native_failed_never_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ) +} +#[test] +fn test_cw20_failed_never_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::Never, + RefundReceiver::Dao, + ) +} + +#[test] +fn test_native_passed_passed_refund() { + test_native_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ) +} +#[test] +fn test_cw20_passed_passed_refund() { + test_cw20_permutation( + EndStatus::Passed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Proposer, + ) +} + +#[test] +fn test_native_failed_passed_refund() { + test_native_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ) +} +#[test] +fn test_cw20_failed_passed_refund() { + test_cw20_permutation( + EndStatus::Failed, + DepositRefundPolicy::OnlyPassed, + RefundReceiver::Dao, + ) +} + +// See: +#[test] +fn test_multiple_open_proposals() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + mint_natives(&mut app, "ekez", coins(20, "ujuno")); + let first_id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + let second_id = make_proposal( + &mut app, + pre_propose, + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + // Finish up the first proposal. + let new_status = vote( + &mut app, + proposal_single.clone(), + "ekez", + first_id, + Vote::Yes, + ); + assert_eq!(Status::Passed, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(0, balance.u128()); + + execute_proposal(&mut app, proposal_single.clone(), "ekez", first_id); + + // First proposal refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + // Finish up the second proposal. + let new_status = vote( + &mut app, + proposal_single.clone(), + "ekez", + second_id, + Vote::No, + ); + assert_eq!(Status::Rejected, new_status); + + // Still zero. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(10, balance.u128()); + + close_proposal(&mut app, proposal_single, "ekez", second_id); + + // All deposits have been refunded. + let balance = get_balance_native(&app, "ekez", "ujuno"); + assert_eq!(20, balance.u128()); +} + +#[test] +fn test_set_version() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr: _, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + let info: ContractVersion = from_slice( + &app.wrap() + .query_wasm_raw(pre_propose, "contract_info".as_bytes()) + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!( + ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string() + }, + info + ) +} + +#[test] +fn test_permissions() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, // no open proposal submission. + ); + + let err: PreProposeError = app + .execute_contract( + core_addr, + pre_propose.clone(), + &ExecuteMsg::ProposalCompletedHook { + proposal_id: 1, + new_status: Status::Closed, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotModule {}); + + // Non-members may not propose when open_propose_submission is + // disabled. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + pre_propose, + &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![], + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotMember {}) +} + +#[test] +fn test_propose_open_proposal_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + true, // yes, open proposal submission. + ); + + // Non-member proposes. + mint_natives(&mut app, "nonmember", coins(10, "ujuno")); + let id = make_proposal( + &mut app, + pre_propose, + proposal_single.clone(), + "nonmember", + &coins(10, "ujuno"), + ); + // Member votes. + let new_status = vote(&mut app, proposal_single, "ekez", id, Vote::Yes); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_no_deposit_required_open_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, None, true, // yes, open proposal submission. + ); + + // Non-member proposes. + let id = make_proposal( + &mut app, + pre_propose, + proposal_single.clone(), + "nonmember", + &[], + ); + // Member votes. + let new_status = vote(&mut app, proposal_single, "ekez", id, Vote::Yes); + assert_eq!(Status::Passed, new_status) +} + +#[test] +fn test_no_deposit_required_members_submission() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single, + pre_propose, + } = setup_default_test( + &mut app, None, false, // no open proposal submission. + ); + + // Non-member proposes and this fails. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("nonmember"), + 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![], + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotMember {}); + + 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_execute_extension_does_nothing() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr: _, + proposal_single: _, + pre_propose, + } = setup_default_test( + &mut app, None, false, // no open proposal submission. + ); + + let res = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose, + &ExecuteMsg::Extension { + msg: Empty::default(), + }, + &[], + ) + .unwrap(); + + // There should be one event which is the invocation of the contract. + assert_eq!(res.events.len(), 1); + assert_eq!(res.events[0].ty, "execute".to_string()); + assert_eq!(res.events[0].attributes.len(), 1); + assert_eq!( + res.events[0].attributes[0].key, + "_contract_addr".to_string() + ) +} + +#[test] +#[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] +fn test_instantiate_with_zero_native_deposit() { + let mut app = App::default(); + + let cps_id = app.store_code(cw_dao_proposal_single_contract()); + + let proposal_module_instantiate = { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + cps::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::zero(), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } + }; + + // Should panic. + instantiate_with_cw4_groups_governance( + &mut app, + cps_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); +} + +#[test] +#[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] +fn test_instantiate_with_zero_cw20_deposit() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20_base_default(&mut app); + + let cps_id = app.store_code(cw_dao_proposal_single_contract()); + + let proposal_module_instantiate = { + let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + + cps::msg::InstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(86400), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_addr.into_string()), + }, + amount: Uint128::zero(), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "baby's first pre-propose module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + } + }; + + // Should panic. + instantiate_with_cw4_groups_governance( + &mut app, + cps_id, + to_binary(&proposal_module_instantiate).unwrap(), + Some(vec![ + cw20::Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(9), + }, + cw20::Cw20Coin { + address: "keze".to_string(), + amount: Uint128::new(8), + }, + ]), + ); +} + +#[test] +fn test_update_config() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + open_proposal_submission: false + } + ); + + let id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &[], + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never, + }), + true, + ); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + open_proposal_submission: true, + } + ); + + // Old proposal should still have same deposit info. + let info = get_deposit_info(&app, pre_propose.clone(), id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: None, + proposer: Addr::unchecked("ekez"), + } + ); + + // New proposals should have the new deposit info. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let new_id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + let info = get_deposit_info(&app, pre_propose.clone(), new_id); + assert_eq!( + info, + DepositInfoResponse { + deposit_info: Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Native("ujuno".to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Never + }), + proposer: Addr::unchecked("ekez"), + } + ); + + // Both proposals should be allowed to complete. + vote(&mut app, proposal_single.clone(), "ekez", id, Vote::Yes); + vote(&mut app, proposal_single.clone(), "ekez", new_id, Vote::Yes); + execute_proposal(&mut app, proposal_single.clone(), "ekez", id); + execute_proposal(&mut app, proposal_single.clone(), "ekez", new_id); + // Deposit should not have been refunded (never policy in use). + let balance = get_balance_native(&app, "ekez", "ujuno"); + 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); + assert_eq!(err, PreProposeError::NotDao {}); +} + +#[test] +fn test_withdraw() { + let mut app = App::default(); + + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + proposal_single.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NotDao {}); + + let err = withdraw_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + assert_eq!(err, PreProposeError::NothingToWithdraw {}); + + let err = withdraw_should_fail(&mut app, pre_propose.clone(), core_addr.as_str(), None); + assert_eq!(err, PreProposeError::NoWithdrawalDenom {}); + + // Turn on native deposits. + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + // Withdraw with no specified denom - should fall back to the one + // in the config. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(10)); + + // Withdraw again, this time specifying a native denomination. + mint_natives(&mut app, pre_propose.as_str(), coins(10, "ujuno")); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(20)); + + // Make a proposal with the native tokens to put some in the system. + mint_natives(&mut app, "ekez", coins(10, "ujuno")); + let native_id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &coins(10, "ujuno"), + ); + + // Update the config to use a cw20 token. + let cw20_address = instantiate_cw20_base_default(&mut app); + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_address.to_string()), + }, + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + increase_allowance( + &mut app, + "ekez", + &pre_propose, + cw20_address.clone(), + Uint128::new(10), + ); + let cw20_id = make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &[], + ); + + // There is now a pending proposal and cw20 tokens in the + // pre-propose module that should be returned on that proposal's + // completion. To make things interesting, we withdraw those + // tokens which should cause the status change hook on the + // proposal's execution to fail as we don't have sufficent balance + // to return the deposit. + withdraw(&mut app, pre_propose.clone(), core_addr.as_str(), None); + let balance = get_balance_cw20(&app, &cw20_address, core_addr.as_str()); + assert_eq!(balance, Uint128::new(10)); + + // Proposal should still be executable! We just get removed from + // the proposal module's hook receiver list. + vote( + &mut app, + proposal_single.clone(), + "ekez", + cw20_id, + Vote::Yes, + ); + execute_proposal(&mut app, proposal_single.clone(), "ekez", cw20_id); + + // Make sure the proposal module has fallen back to anyone can + // propose becuase of our malfunction. + let proposal_creation_policy: ProposalCreationPolicy = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &cps::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + assert_eq!(proposal_creation_policy, ProposalCreationPolicy::Anyone {}); + + // Close out the native proposal and it's deposit as well. + vote( + &mut app, + proposal_single.clone(), + "ekez", + native_id, + Vote::No, + ); + close_proposal(&mut app, proposal_single.clone(), "ekez", native_id); + withdraw( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + Some(UncheckedDenom::Native("ujuno".to_string())), + ); + let balance = get_balance_native(&app, core_addr.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(30)); +} + +#[test] +fn test_hook_management() { + let app = &mut App::default(); + let DefaultTestSetup { + core_addr, + proposal_single: _, + pre_propose, + } = setup_default_test(app, None, true); + + add_hook(app, core_addr.as_str(), &pre_propose, "one"); + add_hook(app, core_addr.as_str(), &pre_propose, "two"); + + remove_hook(app, core_addr.as_str(), &pre_propose, "one"); + + let hooks = query_hooks(app, pre_propose).hooks; + assert_eq!(hooks, vec!["two".to_string()]) +} diff --git a/contracts/proposal/dao-proposal-condorcet/.cargo/config b/contracts/proposal/dao-proposal-condorcet/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/proposal/dao-proposal-condorcet/Cargo.toml b/contracts/proposal/dao-proposal-condorcet/Cargo.toml new file mode 100644 index 000000000..a53910b27 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name ="dao-proposal-condorcet" +authors = ["ekez "] +description = "A DAO DAO proposal module with ranked-choice, Condorcet voting." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw-utils = { workspace = true } +dao-voting = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } +cw-multi-test = { workspace = true } +dao-dao-core = { workspace = true, features = ["library"] } +dao-testing = { workspace = true } +dao-voting-cw4 = { workspace = true } +cw4-group = { workspace = true } +cw4 = { workspace = true } +anyhow = { workspace = true } diff --git a/contracts/proposal/dao-proposal-condorcet/README.md b/contracts/proposal/dao-proposal-condorcet/README.md new file mode 100644 index 000000000..ab9e1bca4 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/README.md @@ -0,0 +1,32 @@ +This is a DAO DAO proposal module which implements The Condorcet +Method. + +https://www.princeton.edu/~cuff/voting/theory.html + +This module lacks many basic features. For example, proposals and +choices do not have human readable names and descriptions. For this +first version, the goal is to build a correct, secure, and gas +efficent voting system that may be audited, not to build a proposal +module that is ready for use with humans and a frontend. + +To this end, this module differs from `dao-proposal-single` and +`dao-proposal-multiple` in that it does not: + +1. support revoting, +2. integrate with pre-propose modules, nor +3. support proposal and vote hooks + +The ranked choice voting system used is described in detail +[here](./gercv.pdf). This contract will make no sense unless you read +that PDF first as there is a fair bit of math. + +> what works reliably +> is to know the raw silk, +> hold the uncut wood. +> Need little, +> want less. +> Forget the rules. +> Be untroubled. + +- [Tao Te Ching (Ursula Le Guin transaltion)](https://github.com/lovingawareness/tao-te-ching/blob/master/Ursula%20K%20Le%20Guin.md) + diff --git a/contracts/proposal/dao-proposal-condorcet/examples/schema.rs b/contracts/proposal/dao-proposal-condorcet/examples/schema.rs new file mode 100644 index 000000000..6a94b72b8 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use dao_proposal_condorcet::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/proposal/dao-proposal-condorcet/gercv.pdf b/contracts/proposal/dao-proposal-condorcet/gercv.pdf new file mode 100644 index 000000000..854a258c8 Binary files /dev/null and b/contracts/proposal/dao-proposal-condorcet/gercv.pdf differ diff --git a/contracts/proposal/dao-proposal-condorcet/schema/dao-proposal-condorcet.json b/contracts/proposal/dao-proposal-condorcet/schema/dao-proposal-condorcet.json new file mode 100644 index 000000000..bf32a13d6 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/schema/dao-proposal-condorcet.json @@ -0,0 +1,2396 @@ +{ + "contract_name": "dao-proposal-condorcet", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "close_proposals_on_execution_failure", + "quorum", + "voting_period" + ], + "properties": { + "close_proposals_on_execution_failure": { + "type": "boolean" + }, + "min_voting_period": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "voting_period": { + "$ref": "#/definitions/Duration" + } + }, + "additionalProperties": false, + "definitions": { + "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 + } + ] + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "choices" + ], + "properties": { + "choices": { + "type": "array", + "items": { + "$ref": "#/definitions/Choice" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "vote": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "close" + ], + "properties": { + "close": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "set_config" + ], + "properties": { + "set_config": { + "$ref": "#/definitions/UncheckedConfig" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Choice": { + "type": "object", + "required": [ + "msgs" + ], + "properties": { + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "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 + } + ] + }, + "Empty": { + "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" + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + }, + "UncheckedConfig": { + "type": "object", + "required": [ + "close_proposals_on_execution_failure", + "quorum", + "voting_period" + ], + "properties": { + "close_proposals_on_execution_failure": { + "type": "boolean" + }, + "min_voting_period": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "voting_period": { + "$ref": "#/definitions/Duration" + } + }, + "additionalProperties": false + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "proposal" + ], + "properties": { + "proposal": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "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 + }, + { + "description": "Returns the proposal ID that will be assigned to the next proposal created.", + "type": "object", + "required": [ + "next_proposal_id" + ], + "properties": { + "next_proposal_id": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": null, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "close_proposals_on_execution_failure", + "quorum", + "voting_period" + ], + "properties": { + "close_proposals_on_execution_failure": { + "type": "boolean" + }, + "min_voting_period": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "voting_period": { + "$ref": "#/definitions/Duration" + } + }, + "additionalProperties": false, + "definitions": { + "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 + } + ] + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "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" + }, + "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 + } + } + }, + "next_proposal_id": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalResponse", + "type": "object", + "required": [ + "proposal", + "tally" + ], + "properties": { + "proposal": { + "$ref": "#/definitions/Proposal" + }, + "tally": { + "$ref": "#/definitions/Tally" + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Cell": { + "description": "A small type for storing a integer that can store numbers in [-2^128, 2^128]. `0` is considered neither positive, nor negative.\n\n# Example\n\n```ignore use cosmwasm_std::Uint128;\n\nlet c = Cell::Positive(Uint128::new(1)); let c = c.decrement(Uint128::new(2)); assert_eq!(c, Cell::Negative(Uint128::new(1))); ```", + "oneOf": [ + { + "type": "string", + "enum": [ + "zero" + ] + }, + { + "type": "object", + "required": [ + "positive" + ], + "properties": { + "positive": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "negative" + ], + "properties": { + "negative": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + ] + }, + "Choice": { + "type": "object", + "required": [ + "msgs" + ], + "properties": { + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "M": { + "description": "M\n\nA NxN matrix for which M[x, y] == -M[y, x].\n\nIndicies may be incremented or decremented. When index (x, y) is incremented, index (y, x) is decremented with the reverse applying when decrementing an index.\n\nInvariant: indicies along the diagonal must never be incremented or decremented.\n\nThe contents of the matrix are not avaliable, though consumers may call the `stats` method which returns information about the first positive column, or the column closest to containing all positive values.", + "type": "object", + "required": [ + "cells", + "n" + ], + "properties": { + "cells": { + "type": "array", + "items": { + "$ref": "#/definitions/Cell" + } + }, + "n": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "Proposal": { + "type": "object", + "required": [ + "choices", + "close_on_execution_failure", + "id", + "last_status", + "proposer", + "quorum", + "total_power" + ], + "properties": { + "choices": { + "type": "array", + "items": { + "$ref": "#/definitions/Choice" + } + }, + "close_on_execution_failure": { + "type": "boolean" + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "last_status": { + "$ref": "#/definitions/Status" + }, + "min_voting_period": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "$ref": "#/definitions/Addr" + }, + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "total_power": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has passed.", + "type": "object", + "required": [ + "passed" + ], + "properties": { + "passed": { + "type": "object", + "required": [ + "winner" + ], + "properties": { + "winner": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "Tally": { + "description": "Stores the state of a ranked choice election by wrapping a `M` matrix and maintaining:\n\nLM[x][y] = |x > y| - |y > x|\n\nOr in english \"the number of times x has beaten y\" minus \"the number of times y has beaten x\". This construction provides that if a column holds all positive, non-zero values then the corresponding candidate is the Condorcet winner. A Condorcet winner is undisputed if it's smallest margin of victory is larger than the outstanding voting power.", + "type": "object", + "required": [ + "expiration", + "m", + "power_outstanding", + "start_height", + "winner" + ], + "properties": { + "expiration": { + "description": "When this tally will stop accepting votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "m": { + "$ref": "#/definitions/M" + }, + "power_outstanding": { + "description": "Amount of voting power that has yet to vote in this tally.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "start_height": { + "description": "The block height that this tally began at.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "winner": { + "description": "The current winner. Always up to date and updated on vote.", + "allOf": [ + { + "$ref": "#/definitions/Winner" + } + ] + } + }, + "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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Winner": { + "oneOf": [ + { + "type": "string", + "enum": [ + "never", + "none" + ] + }, + { + "type": "object", + "required": [ + "some" + ], + "properties": { + "some": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "undisputed" + ], + "properties": { + "undisputed": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + ] + } + } + } + } +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/cell.rs b/contracts/proposal/dao-proposal-condorcet/src/cell.rs new file mode 100644 index 000000000..e3ac4d061 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/cell.rs @@ -0,0 +1,94 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Uint128; + +/// A small type for storing a integer that can store numbers in +/// [-2^128, 2^128]. `0` is considered neither positive, nor negative. +/// +/// # Example +/// +/// ```ignore +/// use cosmwasm_std::Uint128; +/// +/// let c = Cell::Positive(Uint128::new(1)); +/// let c = c.decrement(Uint128::new(2)); +/// assert_eq!(c, Cell::Negative(Uint128::new(1))); +/// ``` +#[cw_serde] +#[derive(Copy)] +pub(crate) enum Cell { + Positive(Uint128), + Zero, + Negative(Uint128), +} + +#[allow(clippy::comparison_chain)] +impl Cell { + pub fn increment(self, amount: Uint128) -> Self { + match self { + Cell::Positive(n) => Cell::Positive(n + amount), + Cell::Zero => Cell::Positive(amount), + Cell::Negative(n) => { + if amount == n { + Cell::Zero + } else if amount > n { + Cell::Positive(amount - n) + } else { + Cell::Negative(n - amount) + } + } + } + } + + pub fn decrement(self, amount: Uint128) -> Self { + match self { + Cell::Positive(n) => { + if amount == n { + Cell::Zero + } else if amount > n { + Cell::Negative(amount - n) + } else { + Cell::Positive(n - amount) + } + } + Cell::Zero => Cell::Negative(amount), + Cell::Negative(n) => Cell::Negative(n + amount), + } + } + + pub fn invert(self) -> Self { + match self { + Cell::Positive(n) => Cell::Negative(n), + Cell::Zero => Cell::Zero, + Cell::Negative(n) => Cell::Positive(n), + } + } +} + +impl Default for Cell { + fn default() -> Self { + Self::Zero + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_increment_to_zero() { + let mut cell = Cell::default(); + for i in 1..100 { + cell = cell.increment(Uint128::new(i)); + cell = cell.decrement(Uint128::new(i)); + } + assert_eq!(cell, Cell::Zero); + } + + #[test] + fn can_hold_max() { + let cell = Cell::Positive(Uint128::MAX) + .decrement(Uint128::MAX) + .decrement(Uint128::MAX); + assert_eq!(cell, Cell::Negative(Uint128::MAX)) + } +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/config.rs b/contracts/proposal/dao-proposal-condorcet/src/config.rs new file mode 100644 index 000000000..7e68d052e --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/config.rs @@ -0,0 +1,38 @@ +use cosmwasm_schema::cw_serde; +use cw_utils::Duration; +use dao_voting::{ + threshold::{validate_quorum, PercentageThreshold}, + voting::validate_voting_period, +}; + +use crate::ContractError; + +#[cw_serde] +pub struct UncheckedConfig { + pub quorum: PercentageThreshold, + pub voting_period: Duration, + pub min_voting_period: Option, + pub close_proposals_on_execution_failure: bool, +} + +#[cw_serde] +pub(crate) struct Config { + pub quorum: PercentageThreshold, + pub voting_period: Duration, + pub min_voting_period: Option, + pub close_proposals_on_execution_failure: bool, +} + +impl UncheckedConfig { + pub(crate) fn into_checked(self) -> Result { + validate_quorum(&self.quorum)?; + let (min_voting_period, voting_period) = + validate_voting_period(self.min_voting_period, self.voting_period)?; + Ok(Config { + quorum: self.quorum, + close_proposals_on_execution_failure: self.close_proposals_on_execution_failure, + voting_period, + min_voting_period, + }) + } +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/contract.rs b/contracts/proposal/dao-proposal-condorcet/src/contract.rs new file mode 100644 index 000000000..b436b76e4 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/contract.rs @@ -0,0 +1,279 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Reply, Response, StdResult, +}; + +use cw2::set_contract_version; +use dao_voting::reply::TaggedReplyId; +use dao_voting::voting::{get_total_power, get_voting_power}; + +use crate::config::UncheckedConfig; +use crate::error::ContractError; +use crate::msg::{Choice, ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::proposal::{Proposal, ProposalResponse, Status}; +use crate::state::{next_proposal_id, CONFIG, DAO, PROPOSAL, TALLY, VOTE}; +use crate::tally::Tally; +use crate::vote::Vote; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-proposal-condorcet"; +pub(crate) 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)?; + + DAO.save(deps.storage, &info.sender)?; + CONFIG.save(deps.storage, &msg.into_checked()?)?; + + Ok(Response::default() + .add_attribute("method", "instantiate") + .add_attribute("creator", info.sender)) +} + +// the key to this contract being gas efficent [1] is that the cost of +// voting does not increase with the number of votes cast, and that +// +// ``` +// gas(vote) <= gas(propose) && gas(execute) <= gas(propose) +// ``` +// +// that being true, you will never be able to create a proposal that +// can not be voted on and executed inside gas limits. +// +// in terms of storage costs: +// +// propose: proposal_load + proposal_store + tally_load + tally_store + config_load +// execute: proposal_load + proposal_store + tally_load +// vote: tally_load + tally_store + vote_load + vote_store +// +// so we are good so long as: +// +// `vote_load + vote_store <= proposal_load + proposal_store + config_load` +// +// this is true so long as a vote is smaller than a proposal in +// storage which is true because proposals store `choices = +// Vec>`, `choices.len() = vote.len()`, vote is a +// `Vec`, even an empty vec must contain it's length which is a +// usize, so `sizeof(Vec) <= sizeof(Vec) <= +// sizeof(Vec) => sizeof(vote) <= sizeof(proposal)`. +// +// in terms of other costs: +// +// propose: query_voting_power + compute_winner [2] +// execute: query_voting_power +// vote: query_voting_power + compute_winner +// +// so we're good there as well. +// +// [1] we need to be gas efficent in this way because the size of the +// Tally type grows with candidates^2 and thus can be too large to +// load from storage. we need to make sure that if this is the +// case, the proposal fails to be created. the bad outcome we're +// trying to avoid here is a proposal that is created but can not +// be voted on or executed. +// [2] Tally::new computes the winner over the new matrix so that this +// is the case. + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Propose { choices } => execute_propose(deps, env, info, choices), + ExecuteMsg::Vote { proposal_id, vote } => execute_vote(deps, env, info, proposal_id, vote), + ExecuteMsg::Execute { proposal_id } => execute_execute(deps, env, info, proposal_id), + ExecuteMsg::Close { proposal_id } => execute_close(deps, env, info, proposal_id), + + ExecuteMsg::SetConfig(config) => execute_set_config(deps, info, config), + } +} + +fn execute_propose( + deps: DepsMut, + env: Env, + info: MessageInfo, + choices: Vec, +) -> Result { + let dao = DAO.load(deps.storage)?; + let sender_voting_power = get_voting_power(deps.as_ref(), info.sender.clone(), &dao, None)?; + if sender_voting_power.is_zero() { + return Err(ContractError::ZeroVotingPower {}); + } + + let config = CONFIG.load(deps.storage)?; + + let id = next_proposal_id(deps.storage)?; + let total_power = get_total_power(deps.as_ref(), &dao, None)?; + + if choices.is_empty() { + return Err(ContractError::ZeroChoices {}); + } + + let none_of_the_above = Choice { msgs: vec![] }; + let mut choices = choices; + choices.push(none_of_the_above); + + let tally = Tally::new( + choices.len() as u32, + total_power, + env.block.height, + config.voting_period.after(&env.block), + ); + TALLY.save(deps.storage, id, &tally)?; + + let mut proposal = Proposal::new(&env.block, &config, info.sender, id, choices, total_power); + proposal.update_status(&env.block, &tally); + PROPOSAL.save(deps.storage, id, &proposal)?; + + Ok(Response::default() + .add_attribute("method", "propose") + .add_attribute("proposal_id", proposal.id.to_string()) + .add_attribute("proposer", proposal.proposer)) +} + +fn execute_vote( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u32, + vote: Vec, +) -> Result { + let tally = TALLY.load(deps.storage, proposal_id)?; + let sender_power = get_voting_power( + deps.as_ref(), + info.sender.clone(), + &DAO.load(deps.storage)?, + Some(tally.start_height), + )?; + if sender_power.is_zero() { + Err(ContractError::ZeroVotingPower {}) + } else if VOTE.has(deps.storage, (proposal_id, info.sender.clone())) { + Err(ContractError::Voted {}) + } else if tally.expired(&env.block) { + Err(ContractError::Expired {}) + } else { + let vote = Vote::new(vote, tally.candidates())?; + VOTE.save(deps.storage, (proposal_id, info.sender.clone()), &vote)?; + + let mut tally = tally; + tally.add_vote(vote, sender_power); + TALLY.save(deps.storage, proposal_id, &tally)?; + + Ok(Response::default() + .add_attribute("method", "vote") + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("voter", info.sender) + .add_attribute("power", sender_power)) + } +} + +fn execute_execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u32, +) -> Result { + let tally = TALLY.load(deps.storage, proposal_id)?; + let dao = DAO.load(deps.storage)?; + let sender_power = get_voting_power( + deps.as_ref(), + info.sender.clone(), + &dao, + Some(tally.start_height), + )?; + if sender_power.is_zero() { + return Err(ContractError::ZeroVotingPower {}); + } + + let mut proposal = PROPOSAL.load(deps.storage, proposal_id)?; + if let Status::Passed { winner } = proposal.update_status(&env.block, &tally) { + let msgs = proposal.set_executed(dao, winner)?; + PROPOSAL.save(deps.storage, proposal_id, &proposal)?; + + Ok(Response::default() + .add_attribute("method", "execute") + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("executor", info.sender) + .add_submessage(msgs)) + } else { + Err(ContractError::Unexecutable {}) + } +} + +fn execute_close( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u32, +) -> Result { + let tally = TALLY.load(deps.storage, proposal_id)?; + let mut proposal = PROPOSAL.load(deps.storage, proposal_id)?; + if let Status::Rejected = proposal.update_status(&env.block, &tally) { + proposal.set_closed(); + PROPOSAL.save(deps.storage, proposal_id, &proposal)?; + + Ok(Response::default() + .add_attribute("method", "close") + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("closer", info.sender)) + } else { + Err(ContractError::Unclosable {}) + } +} + +fn execute_set_config( + deps: DepsMut, + info: MessageInfo, + config: UncheckedConfig, +) -> Result { + if info.sender != DAO.load(deps.storage)? { + Err(ContractError::NotDao {}) + } else { + CONFIG.save(deps.storage, &config.into_checked()?)?; + Ok(Response::default() + .add_attribute("method", "update_config") + .add_attribute("updater", info.sender)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Proposal { id } => { + let mut proposal = PROPOSAL.load(deps.storage, id)?; + let tally = TALLY.load(deps.storage, id)?; + proposal.update_status(&env.block, &tally); + to_binary(&ProposalResponse { proposal, tally }) + } + QueryMsg::Config {} => to_binary(&CONFIG.load(deps.storage)?), + QueryMsg::NextProposalId {} => to_binary(&next_proposal_id(deps.storage)?), + QueryMsg::Dao {} => to_binary(&DAO.load(deps.storage)?), + QueryMsg::Info {} => to_binary(&dao_interface::voting::InfoResponse { + info: cw2::get_contract_version(deps.storage)?, + }), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + let repl = TaggedReplyId::new(msg.id)?; + match repl { + TaggedReplyId::FailedProposalExecution(proposal_id) => { + let mut proposal = PROPOSAL.load(deps.storage, proposal_id as u32)?; + proposal.set_execution_failed(); + PROPOSAL.save(deps.storage, proposal_id as u32, &proposal)?; + Ok(Response::default() + .add_attribute("proposal_execution_failed", proposal_id.to_string())) + } + _ => unimplemented!("pre-propose and hooks not yet supported"), + } +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/error.rs b/contracts/proposal/dao-proposal-condorcet/src/error.rs new file mode 100644 index 000000000..6d280bfae --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/error.rs @@ -0,0 +1,40 @@ +use cosmwasm_std::StdError; +use dao_voting::{error::VotingError, reply::error::TagError, threshold::ThresholdError}; +use thiserror::Error; + +use crate::vote::VoteError; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + #[error(transparent)] + InvalidVote(#[from] VoteError), + #[error(transparent)] + Threshold(#[from] ThresholdError), + #[error(transparent)] + Voting(#[from] VotingError), + #[error(transparent)] + Tag(#[from] TagError), + + #[error("non-zero voting power required to perform this action")] + ZeroVotingPower {}, + + #[error("only proposals that are in the passed state may be executed")] + Unexecutable {}, + + #[error("only rejected proposals may be closed")] + Unclosable {}, + + #[error("only the DAO my perform this action")] + NotDao {}, + + #[error("already voted")] + Voted {}, + + #[error("only non-expired proposals may be voted on")] + Expired {}, + + #[error("must specify at least one choice for proposal")] + ZeroChoices {}, +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/lib.rs b/contracts/proposal/dao-proposal-condorcet/src/lib.rs new file mode 100644 index 000000000..ee72093ad --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/lib.rs @@ -0,0 +1,16 @@ +mod cell; +pub mod config; +pub mod contract; +mod error; +mod m; +pub mod msg; +pub mod proposal; +pub mod state; +pub mod tally; + +#[cfg(test)] +mod testing; + +pub mod vote; + +pub use crate::error::ContractError; diff --git a/contracts/proposal/dao-proposal-condorcet/src/m.rs b/contracts/proposal/dao-proposal-condorcet/src/m.rs new file mode 100644 index 000000000..1c7193382 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/m.rs @@ -0,0 +1,387 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Uint128, Uint256}; + +use crate::cell::Cell; + +/// M +/// +/// A NxN matrix for which M[x, y] == -M[y, x]. +/// +/// Indicies may be incremented or decremented. When index (x, y) is +/// incremented, index (y, x) is decremented with the reverse applying +/// when decrementing an index. +/// +/// Invariant: indicies along the diagonal must never be incremented +/// or decremented. +/// +/// The contents of the matrix are not avaliable, though consumers may +/// call the `stats` method which returns information about the first +/// positive column, or the column closest to containing all positive +/// values. +#[cw_serde] +pub(crate) struct M { + cells: Vec, + // the n in NxN. stored instead of re-computing on use as: + // + // cells.len = n * (n - 1) / 2 + // + // which has a square root if you try and extract n from it. + pub n: u32, +} + +pub(crate) enum Stats { + PositiveColumn { + /// Index of the column that is positive. + col: u32, + /// Smallest value in the column's distance from zero. + min_margin: Uint128, + }, + NoPositiveColumn { + /// False if there exists a column where: + /// + /// distance_from_positivity(col) <= power_outstanding * (N-1) + /// && max_negative_magnitude(col) < power_outstanding + no_winnable_columns: bool, + }, +} + +impl M { + pub fn new(n: u32) -> Self { + M { + // example 4x4 M: + // + // \ 1 2 3 + // -1 \ 4 5 + // -2 -4 \ 6 + // -3 -5 -6 \ + // + // `cells` stores all the values in the upper diagonal of + // M. there are `N-1 + N-2 .. + 1` or `N (N-1) / 2` cells. + cells: vec![Cell::default(); (n * (n - 1) / 2) as usize], + n, + } + } + + /// Gets the index in `self.cells` that corresponds to the index (x, y) in M. + /// + /// Invariant: x > y as otherwise the upper diagonal + /// (`self.cells`) will not contain an entry for the index. + fn index(&self, (x, y): (u32, u32)) -> u32 { + let n = self.n; + // the start of the row in `self.cells`. + // + // the easiest way to conceptualize this is + // geometrically. `y*n` is the area of the whole matrix up to + // row `y`, and thus the start index of the row if + // `self.cells` was not diagonalized [1]. `(y + 1) * y / 2` is the + // area of the space that is not in the upper diagonal. + // + // whole_area - area_of_non_diagonal = area_of_diagonal + // + // because we're in the land of discrete math and things are + // zero-indexed, area_of_diagonal == start_of_row. + let row = y * n - (y + 1) * y / 2; + // we know that x > y, so to get the index in `self.cells` we + // offset x by the distance of x from the line x = y (the + // diagonal), as `self.cells`' first index corresponds to the + // first item in that row of the upper diagonal. + let offset = x - (y + 1); + row + offset + } + + pub(crate) fn get(&self, (x, y): (u32, u32)) -> Cell { + if x < y { + self.get((y, x)).invert() + } else { + let i = self.index((x, y)) as usize; + self.cells[i] + } + } + + pub fn increment(&mut self, (x, y): (u32, u32), amount: Uint128) { + debug_assert!(x != y); + if x < y { + self.decrement((y, x), amount) + } else { + let i = self.index((x, y)) as usize; + self.cells[i] = self.cells[i].increment(amount) + } + } + + pub fn decrement(&mut self, (x, y): (u32, u32), amount: Uint128) { + debug_assert!(x != y); + if x < y { + self.increment((y, x), amount) + } else { + let i = self.index((x, y)) as usize; + self.cells[i] = self.cells[i].decrement(amount) + } + } + + /// Computes statistics about M which are used to determine if a + /// proposal has passed or may be rejected early. + /// + /// Code comments refer to this proof of conditions for early + /// rejection: + /// + /// https://github.com/DA0-DA0/dao-contracts/wiki/Proofs-of-early-rejection-cases-for-Condorcet-proposals + pub fn stats(&self, power_outstanding: Uint128) -> Stats { + let n = self.n; + let mut no_winnable_columns = true; + for col in 0..n { + let mut distance_from_positivity = Uint256::zero(); + let mut min_margin = Uint128::MAX; + let mut max_negative = Uint128::zero(); + for row in 0..n { + if row != col { + match self.get((col, row)) { + Cell::Positive(p) => { + if p < min_margin { + min_margin = p + } + } + Cell::Negative(v) => { + if v > max_negative { + max_negative = v; + } + distance_from_positivity += Uint256::from(v) + Uint256::one(); + } + Cell::Zero => distance_from_positivity += Uint256::one(), + } + } + } + if distance_from_positivity.is_zero() { + // there is only ever one positive column, as the + // symmetry of this matrix means that if there is a + // positive column there is also a row with negative + // values in every column except that one. so, we can + // return early here. + return Stats::PositiveColumn { col, min_margin }; + } + + // a column is winnable if both claim A and B are false (see proof) + if distance_from_positivity <= power_outstanding.full_mul((self.n - 1) as u64) { + // ^ claim_a = false + if max_negative < power_outstanding { + // ^ claim_b = false + no_winnable_columns = false + } + } + } + Stats::NoPositiveColumn { + no_winnable_columns, + } + } +} + +#[cfg(test)] +pub(crate) mod test { + use super::*; + + // prints out the LM in it's full matrix form. looks something + // like this: + // + // ``` + // \ -1 0 0 0 0 0 0 + // 1 \ 1 1 1 1 1 1 + // 0 -1 \ 0 0 0 0 0 + // 0 -1 0 \ 0 0 0 0 + // 0 -1 0 0 \ 0 0 0 + // 0 -1 0 0 0 \ 0 0 + // 0 -1 0 0 0 0 \ 0 + // 0 -1 0 0 0 0 0 \ + // ``` + #[allow(dead_code)] + pub(crate) fn debug_lm(lm: &M) { + for row in 0..lm.n { + for col in 0..lm.n { + if row == col { + eprint!(" \\"); + } else { + let c = lm.get((col, row)); + match c { + Cell::Positive(p) => eprint!(" {p}"), + Cell::Zero => eprint!(" 0"), + Cell::Negative(p) => eprint!(" -{p}"), + } + } + } + eprintln!() + } + } +} + +#[cfg(test)] +mod test_lm { + use super::*; + + fn new_m(n: u32) -> M { + M { + cells: vec![Cell::default(); (n * (n - 1) / 2) as usize], + n, + } + } + + #[test] + fn test_internal_representation() { + let mut m = new_m(4); + m.increment((1, 0), Uint128::new(1)); + m.increment((2, 0), Uint128::new(2)); + m.increment((3, 0), Uint128::new(3)); + m.increment((2, 1), Uint128::new(4)); + m.increment((3, 1), Uint128::new(5)); + m.increment((3, 2), Uint128::new(6)); + + assert_eq!( + m.cells, + (1..7) + .map(|i| Cell::Positive(Uint128::new(i))) + .collect::>() + ) + } + + #[test] + fn test_index() { + let n = 3; + let m = new_m(n); + + let i = m.index((1, 0)); + assert_eq!(i, 0); + } + + #[test] + fn test_create() { + let n = 10; + let m = new_m(n); + + // we now expect this to be a 10 / 10 square. + for x in 0..n { + for y in 0..n { + if x != y { + let c = m.get((x, y)); + assert!(matches!(c, Cell::Zero)) + } + } + } + } + + #[test] + fn test_incrementation() { + // decrement all values for which y < x. all values for which + // y > x should become positive. + let n = 11; + let mut m = new_m(n); + + for x in 0..n { + for y in 0..n { + if y < x { + m.increment((y, x), Uint128::one()) + } + } + } + + for x in 0..n { + for y in 0..n { + if y > x { + assert_eq!(m.get((x, y)), Cell::Positive(Uint128::one())); + assert_eq!(m.get((y, x)), Cell::Negative(Uint128::one())); + } + } + } + } + + #[test] + fn test_stats_positive_column() { + let n = 8; + let mut m = new_m(n); + + for y in 0..n { + if y != 2 { + m.increment((2, y), Uint128::one()) + } + } + + match m.stats(Uint128::zero()) { + Stats::PositiveColumn { col, min_margin } => { + assert_eq!((col, min_margin), (2, Uint128::one())) + } + Stats::NoPositiveColumn { .. } => panic!("expected a positive column"), + } + } + + #[test] + fn test_stats_no_positive_column() { + let n = 8; + let mut m = new_m(n); + + match m.stats(Uint128::new(n as u128)) { + Stats::PositiveColumn { .. } => panic!("expected no positive columns"), + Stats::NoPositiveColumn { + no_winnable_columns, + } => { + // false because there exists a row that may be + // flipped with N voting power remaining, and the + // largest negative in that row is less than the power + // outstanding. + assert!(!no_winnable_columns) + } + } + + for i in 1..n { + m.decrement((i - 1, i), Uint128::new(2)); + } + + // last row here has no negative value and a distance from + // positivity of n - 2. + // + // \ 2 0 0 0 0 0 0 + // -2 \ 2 0 0 0 0 0 + // 0 -2 \ 2 0 0 0 0 + // 0 0 -2 \ 2 0 0 0 + // 0 0 0 -2 \ 2 0 0 + // 0 0 0 0 -2 \ 2 0 + // 0 0 0 0 0 -2 \ 2 + // 0 0 0 0 0 0 -2 \ + + match m.stats(Uint128::new((n - 3) as u128)) { + Stats::PositiveColumn { .. } => panic!("expected no positive columns"), + Stats::NoPositiveColumn { + no_winnable_columns, + } => { + // last column can be flipped. + assert!(!no_winnable_columns) + } + } + + m.decrement((n - 1, n - 3), Uint128::new(1)); + + // \ 2 0 0 0 0 0 0 + // -2 \ 2 0 0 0 0 0 + // 0 -2 \ 2 0 0 0 0 + // 0 0 -2 \ 2 0 0 0 + // 0 0 0 -2 \ 2 0 0 + // 0 0 0 0 -2 \ 2 -1 + // 0 0 0 0 0 -2 \ 2 + // 0 0 0 0 0 1 -2 \ + + match m.stats(Uint128::new(7)) { + Stats::PositiveColumn { .. } => panic!("expected no positive columns"), + Stats::NoPositiveColumn { + no_winnable_columns, + } => { + // there is enough voting power to flip columns n-1 and n-2. + assert!(!no_winnable_columns) + } + } + + match m.stats(Uint128::new(1)) { + Stats::PositiveColumn { .. } => panic!("expected no positive columns"), + Stats::NoPositiveColumn { + no_winnable_columns, + } => { + // there is not enough voting power to flip any columns. + assert!(no_winnable_columns) + } + } + } +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/msg.rs b/contracts/proposal/dao-proposal-condorcet/src/msg.rs new file mode 100644 index 000000000..ab6f5aa12 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/msg.rs @@ -0,0 +1,32 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Empty}; + +use dao_dao_macros::proposal_module_query; + +use crate::config::UncheckedConfig; + +pub type InstantiateMsg = UncheckedConfig; + +#[cw_serde] +pub struct Choice { + pub msgs: Vec>, +} + +#[cw_serde] +pub enum ExecuteMsg { + Propose { choices: Vec }, + Vote { proposal_id: u32, vote: Vec }, + Execute { proposal_id: u32 }, + Close { proposal_id: u32 }, + SetConfig(UncheckedConfig), +} + +#[proposal_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(crate::proposal::ProposalResponse)] + Proposal { id: u32 }, + #[returns(crate::config::Config)] + Config {}, +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/proposal.rs b/contracts/proposal/dao-proposal-condorcet/src/proposal.rs new file mode 100644 index 000000000..155b90cc2 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/proposal.rs @@ -0,0 +1,180 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, Addr, BlockInfo, StdResult, SubMsg, Uint128, WasmMsg}; +use cw_utils::Expiration; +use dao_voting::{ + reply::mask_proposal_execution_proposal_id, threshold::PercentageThreshold, + voting::does_vote_count_pass, +}; + +use crate::{ + config::Config, + msg::Choice, + tally::{Tally, Winner}, +}; + +#[cw_serde] +pub struct Proposal { + last_status: Status, + + pub proposer: Addr, + + pub quorum: PercentageThreshold, + pub min_voting_period: Option, + + pub close_on_execution_failure: bool, + pub total_power: Uint128, + + pub id: u32, + pub choices: Vec, +} + +#[cw_serde] +#[derive(Copy)] +pub enum Status { + /// The proposal is open for voting. + Open, + /// The proposal has been rejected. + Rejected, + /// The proposal has passed. + Passed { winner: u32 }, + /// The proposal has been passed and executed. + Executed, + /// The proposal has failed or expired and has been closed. A + /// proposal deposit refund has been issued if applicable. + Closed, + /// The proposal's execution failed. + ExecutionFailed, +} + +#[cw_serde] +pub struct ProposalResponse { + pub proposal: Proposal, + pub tally: Tally, +} + +fn status(block: &BlockInfo, proposal: &Proposal, tally: &Tally) -> Status { + match proposal.last_status { + Status::Rejected + | Status::Passed { .. } + | Status::Executed + | Status::Closed + | Status::ExecutionFailed => proposal.last_status, + Status::Open => { + if proposal + .min_voting_period + .map_or(false, |min| !min.is_expired(block)) + { + return Status::Open; + } + + let winner = tally.winner; + let expired = tally.expiration.is_expired(block); + let quorum = does_vote_count_pass( + proposal.total_power - tally.power_outstanding, + proposal.total_power, + proposal.quorum, + ); + + if expired && !quorum { + Status::Rejected + } else { + match winner { + Winner::Never => Status::Rejected, + Winner::None => { + if expired { + Status::Rejected + } else { + Status::Open + } + } + Winner::Some(winner) => { + if expired && quorum { + Status::Passed { winner } + } else { + Status::Open + } + } + Winner::Undisputed(winner) => { + if quorum { + Status::Passed { winner } + } else { + Status::Open + } + } + } + } + } + } +} + +impl Proposal { + pub(crate) fn new( + block: &BlockInfo, + config: &Config, + proposer: Addr, + id: u32, + choices: Vec, + total_power: Uint128, + ) -> Self { + Self { + last_status: Status::Open, + + min_voting_period: config.min_voting_period.map(|m| m.after(block)), + quorum: config.quorum, + close_on_execution_failure: config.close_proposals_on_execution_failure, + + id, + proposer, + choices, + total_power, + } + } + + pub(crate) fn update_status(&mut self, block: &BlockInfo, tally: &Tally) -> Status { + self.last_status = status(block, self, tally); + self.last_status + } + + pub fn status(&self, block: &BlockInfo, tally: &Tally) -> Status { + status(block, self, tally) + } + + // To test that status is updated before responding to queries. + #[cfg(test)] + pub fn last_status(&self) -> Status { + self.last_status + } + + pub(crate) fn set_closed(&mut self) { + debug_assert_eq!(self.last_status, Status::Rejected); + + self.last_status = Status::Closed; + } + + /// Sets the proposal's status to executed and returns a + /// submessage to be executed. + pub(crate) fn set_executed(&mut self, dao: Addr, winner: u32) -> StdResult { + debug_assert_eq!(self.last_status, Status::Passed { winner }); + + self.last_status = Status::Executed; + + let msgs = self.choices[winner as usize].msgs.clone(); + let core_exec = WasmMsg::Execute { + contract_addr: dao.into_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::ExecuteProposalHook { msgs })?, + funds: vec![], + }; + Ok(if self.close_on_execution_failure { + let masked_id = mask_proposal_execution_proposal_id(self.id as u64); + SubMsg::reply_on_error(core_exec, masked_id) + } else { + SubMsg::new(core_exec) + }) + } + + pub(crate) fn set_execution_failed(&mut self) { + debug_assert_eq!(self.last_status, Status::Executed); + + self.last_status = Status::ExecutionFailed; + } +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/state.rs b/contracts/proposal/dao-proposal-condorcet/src/state.rs new file mode 100644 index 000000000..a44e1c9b5 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/state.rs @@ -0,0 +1,19 @@ +use cosmwasm_std::{Addr, StdResult, Storage}; +use cw_storage_plus::{Item, Map}; + +use crate::{config::Config, proposal::Proposal, tally::Tally, vote::Vote}; + +pub(crate) const DAO: Item = Item::new("dao"); +pub(crate) const CONFIG: Item = Item::new("config"); + +pub(crate) const TALLY: Map = Map::new("tallys"); +pub(crate) const PROPOSAL: Map = Map::new("proposals"); +pub(crate) const VOTE: Map<(u32, Addr), Vote> = Map::new("votes"); + +pub(crate) fn next_proposal_id(storage: &dyn Storage) -> StdResult { + PROPOSAL + .keys(storage, None, None, cosmwasm_std::Order::Descending) + .next() + .transpose() + .map(|id| id.unwrap_or(0) + 1) +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/tally.rs b/contracts/proposal/dao-proposal-condorcet/src/tally.rs new file mode 100644 index 000000000..2f53c2f4a --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/tally.rs @@ -0,0 +1,117 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{BlockInfo, Uint128}; +use cw_utils::Expiration; + +use crate::{ + m::{Stats, M}, + vote::Vote, +}; + +/// Stores the state of a ranked choice election by wrapping a `M` +/// matrix and maintaining: +/// +/// LM[x][y] = |x > y| - |y > x| +/// +/// Or in english "the number of times x has beaten y" minus "the +/// number of times y has beaten x". This construction provides that +/// if a column holds all positive, non-zero values then the +/// corresponding candidate is the Condorcet winner. A Condorcet +/// winner is undisputed if it's smallest margin of victory is larger +/// than the outstanding voting power. +#[cw_serde] +pub struct Tally { + m: M, + + /// When this tally will stop accepting votes. + pub expiration: Expiration, + /// The block height that this tally began at. + pub start_height: u64, + /// Amount of voting power that has yet to vote in this tally. + pub power_outstanding: Uint128, + /// The current winner. Always up to date and updated on vote. + pub winner: Winner, +} + +#[cw_serde] +#[derive(Copy)] +pub enum Winner { + Never, + None, + Some(u32), + Undisputed(u32), +} + +impl Tally { + pub fn new( + candidates: u32, + total_power: Uint128, + start_height: u64, + expiration: Expiration, + ) -> Self { + let mut tally = Self { + m: M::new(candidates), + power_outstanding: total_power, + winner: Winner::None, + start_height, + expiration, + }; + // compute even though this will always be Winner::None so + // that creating a tally has the same compute cost of adding a + // vote which is needed so that gas(proposal_creation) >= + // gas(vote). + tally.winner = tally.winner(); + tally + } + + pub fn candidates(&self) -> u32 { + self.m.n + } + + pub fn expired(&self, block: &BlockInfo) -> bool { + self.expiration.is_expired(block) + } + + /// Records a vote in the tally. The tally must not be expired. + /// + /// - `vote` a list of candidates sorted in order from most to + /// least favored + /// - `power` the voting power of the voter + /// + /// Invariants: + /// + /// - Voter has not already voted. + /// - Tally is not expired. + pub fn add_vote(&mut self, vote: Vote, power: Uint128) { + for (index, preference) in vote.iter().enumerate() { + // an interesting property of the symetry of M is that in + // recording all the defeats, we also record all of the + // victories. + for defeat in 0..index { + self.m.decrement((*preference, vote[defeat]), power) + } + } + self.power_outstanding -= power; + self.winner = self.winner(); + } + + fn winner(&self) -> Winner { + match self.m.stats(self.power_outstanding) { + Stats::PositiveColumn { col, min_margin } => { + if min_margin > self.power_outstanding { + Winner::Undisputed(col) + } else { + Winner::Some(col) + } + } + Stats::NoPositiveColumn { + no_winnable_columns, + } => { + if no_winnable_columns { + Winner::Never + } else { + Winner::None + } + } + } + } +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/testing/instantiation.rs b/contracts/proposal/dao-proposal-condorcet/src/testing/instantiation.rs new file mode 100644 index 000000000..6bf04106f --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/testing/instantiation.rs @@ -0,0 +1,61 @@ +use cosmwasm_std::Decimal; +use cw_utils::Duration; +use dao_voting::threshold::PercentageThreshold; + +use crate::config::UncheckedConfig; + +use super::suite::SuiteBuilder; + +#[test] +fn test_instantiation() { + let default_config = SuiteBuilder::default().instantiate; + + let suite = SuiteBuilder::default().build(); + let config = suite.query_config(); + + assert_eq!(config, default_config.into_checked().unwrap()) +} + +#[test] +#[should_panic(expected = "Min voting period must be less than or equal to max voting period")] +fn test_instantiate_conflicting_proposal_durations() { + SuiteBuilder::with_config(UncheckedConfig { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + voting_period: Duration::Height(10), + min_voting_period: Some(Duration::Height(11)), + close_proposals_on_execution_failure: true, + }) + .build(); +} + +#[test] +#[should_panic( + expected = "min_voting_period and max_voting_period must have the same units (height or time)" +)] +fn test_instantiate_conflicting_duration_types() { + SuiteBuilder::with_config(UncheckedConfig { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + voting_period: Duration::Height(10), + min_voting_period: Some(Duration::Time(9)), + close_proposals_on_execution_failure: true, + }) + .build(); +} + +#[test] +fn test_instantiate_open_til_expiry() { + SuiteBuilder::with_config(UncheckedConfig { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + voting_period: Duration::Height(10), + min_voting_period: Some(Duration::Height(10)), + close_proposals_on_execution_failure: true, + }) + .build(); + SuiteBuilder::with_config(UncheckedConfig { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + voting_period: Duration::Time(10), + min_voting_period: Some(Duration::Time(10)), + close_proposals_on_execution_failure: true, + }) + .build(); +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/testing/mod.rs b/contracts/proposal/dao-proposal-condorcet/src/testing/mod.rs new file mode 100644 index 000000000..5b8273028 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/testing/mod.rs @@ -0,0 +1,14 @@ +mod instantiation; +mod proposals; +mod suite; +mod tallying; + +// 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:expr) => { + assert!(format!("{:#}", $x.unwrap_err()).contains($e)) + }; +} +pub(crate) use is_error; diff --git a/contracts/proposal/dao-proposal-condorcet/src/testing/proposals.rs b/contracts/proposal/dao-proposal-condorcet/src/testing/proposals.rs new file mode 100644 index 000000000..151790ef8 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/testing/proposals.rs @@ -0,0 +1,238 @@ +use cosmwasm_std::{to_binary, WasmMsg}; +use cw_utils::Duration; + +use crate::{ + config::UncheckedConfig, + msg::ExecuteMsg, + proposal::{ProposalResponse, Status}, + tally::Winner, + testing::suite::unimportant_message, + ContractError, +}; + +use super::{is_error, suite::SuiteBuilder}; + +// a condorcet winner does not exist and the proposal is closed. +#[test] +fn test_proposal_lifecycle_closed() { + let mut suite = SuiteBuilder::default() + .with_voters(&[ + ("blue", 10), + ("violet", 10), + ("magenta", 10), + ("gold", 10), + ("crimson", 10), + ("turquoise", 10), + ]) + .with_proposal(2) + .build(); + + suite.vote("blue", 1, vec![0, 2, 1]).unwrap(); + suite.vote("violet", 1, vec![1, 0, 2]).unwrap(); + suite.vote("magenta", 1, vec![2, 1, 0]).unwrap(); + suite.vote("gold", 1, vec![1, 0, 2]).unwrap(); + suite.vote("crimson", 1, vec![0, 2, 1]).unwrap(); + suite.vote("turquoise", 1, vec![2, 0, 1]).unwrap(); + + suite.a_day_passes(); + + let (winner, status) = suite.query_winner_and_status(1); + assert_eq!(winner, Winner::Never); + assert_eq!(status, Status::Rejected); + + suite.close("crimson", 1).unwrap(); + + let (_, status) = suite.query_winner_and_status(1); + assert_eq!(status, Status::Closed); +} + +#[test] +fn test_make_proposal() { + let mut suite = SuiteBuilder::default().build(); + let id = suite + .propose(suite.sender(), vec![vec![unimportant_message()]]) + .unwrap(); + let ProposalResponse { proposal, tally } = suite.query_proposal(id); + + assert_eq!(proposal.id, id); + assert_eq!(proposal.choices.len(), 2); + assert_eq!(proposal.choices[0].msgs[0], unimportant_message()); + assert_eq!(proposal.choices[1].msgs, vec![]); // none-of-the-above added to the end. + + assert_eq!(tally.candidates(), 2); + assert_eq!(tally.winner, Winner::None); + assert_eq!(tally.power_outstanding, proposal.total_power); + assert_eq!(tally.start_height, suite.block_height()); +} + +#[test] +fn test_proposal_zero_choices() { + let mut suite = SuiteBuilder::default().build(); + let err = suite.propose(suite.sender(), vec![]); + is_error!(err, &ContractError::ZeroChoices {}.to_string()); +} + +#[test] +fn test_no_propose_zero_voting_power() { + let mut suite = SuiteBuilder::default().build(); + let err = suite.propose("someone", vec![]); + is_error!(err, &ContractError::ZeroVotingPower {}.to_string()); +} + +#[test] +fn test_proposal_lifeclyle_execution_failed() { + let mut suite = SuiteBuilder::default().with_proposal(1).build(); + + suite.vote(suite.sender(), 1, vec![0, 1]).unwrap(); + + let (winner, status) = suite.query_winner_and_status(1); + assert_eq!(winner, Winner::Undisputed(0)); + assert_eq!(status, Status::Open); // min voting period! + + suite.a_day_passes(); + + let (_, status) = suite.query_winner_and_status(1); + assert_eq!(status, Status::Passed { winner: 0 }); + + suite.execute(suite.sender(), 1).unwrap(); + + let (winner, status) = suite.query_winner_and_status(1); + assert_eq!(status, Status::ExecutionFailed); + assert_eq!(winner, Winner::Undisputed(0)); +} + +#[test] +fn test_proposal_never_reaches_quorum() { + let mut suite = SuiteBuilder::default() + .with_voters(&[("pleb", 1), ("belp", 10)]) + .with_proposal(2) + .build(); + + suite.vote("pleb", 1, vec![0, 2, 1]).unwrap(); + + // seven days pass + suite.a_week_passes(); + + let (winner, status) = suite.query_winner_and_status(1); + assert_eq!(winner, Winner::Some(0)); + assert_eq!(status, Status::Rejected); +} + +#[test] +fn test_proposal_passes_after_expiry() { + let mut suite = SuiteBuilder::default() + .with_voters(&[("pleb", 15), ("belp", 85)]) + .with_proposal(2) + .build(); + + suite.vote("pleb", 1, vec![0, 2, 1]).unwrap(); + + suite.a_week_passes(); + + let (winner, status) = suite.query_winner_and_status(1); + assert_eq!(winner, Winner::Some(0)); + assert_eq!(status, Status::Passed { winner: 0 }); +} + +#[test] +fn test_no_vote_after_expiry() { + let mut suite = SuiteBuilder::default().with_proposal(1).build(); + + suite.a_week_passes(); + + let err = suite.vote(suite.sender(), 1, vec![0, 1]); + is_error!(err, &ContractError::Expired {}.to_string()); +} + +#[test] +fn test_no_revoting() { + let mut suite = SuiteBuilder::default().with_proposal(1).build(); + + suite.vote(suite.sender(), 1, vec![0, 1]).unwrap(); + + let err = suite.vote(suite.sender(), 1, vec![0, 1]); + is_error!(err, &ContractError::Voted {}.to_string()); +} + +#[test] +fn test_no_vote_zero_power() { + let mut suite = SuiteBuilder::default().with_proposal(1).build(); + let err = suite.vote("somebody", 1, vec![0, 1]); + is_error!(err, &ContractError::ZeroVotingPower {}.to_string()); +} + +#[test] +fn test_proposal_set_config() { + let mut suite = SuiteBuilder::default().build(); + let config = suite.query_config(); + + suite + .propose( + suite.sender(), + vec![vec![WasmMsg::Execute { + contract_addr: suite.condorcet.to_string(), + msg: to_binary(&ExecuteMsg::SetConfig(UncheckedConfig { + quorum: config.quorum, + voting_period: config.voting_period, + min_voting_period: None, + close_proposals_on_execution_failure: false, + })) + .unwrap(), + funds: vec![], + } + .into()]], + ) + .unwrap(); + // before passing the earlier one make another proposal who's + // execution will fail if configs are correctly checked in + // set_config. this proposal failing and entering the + // ExecutionFailed state will indicate that configs are being + // validated and that close_proposal_on_execution_failure is being + // applied on a per-proposal basis. + suite + .propose( + suite.sender(), + vec![vec![WasmMsg::Execute { + contract_addr: suite.condorcet.to_string(), + msg: to_binary(&ExecuteMsg::SetConfig(UncheckedConfig { + quorum: config.quorum, + voting_period: config.voting_period, + min_voting_period: Some(Duration::Height(10)), + close_proposals_on_execution_failure: false, + })) + .unwrap(), + funds: vec![], + } + .into()]], + ) + .unwrap(); + + suite.a_day_passes(); + + suite.vote(suite.sender(), 1, vec![0, 1]).unwrap(); + suite.execute(suite.sender(), 1).unwrap(); + + let new_config = suite.query_config(); + assert_eq!(new_config.quorum, config.quorum); + assert_eq!(new_config.voting_period, config.voting_period); + assert_eq!(new_config.min_voting_period, None); + assert!(!new_config.close_proposals_on_execution_failure); + + suite.vote(suite.sender(), 2, vec![0, 1]).unwrap(); + suite.execute(suite.sender(), 2).unwrap(); + + let (_, status) = suite.query_winner_and_status(2); + assert_eq!(status, Status::ExecutionFailed); +} + +#[test] +fn test_execution_fail_handling() { + let mut suite = SuiteBuilder::default().with_proposal(1); + suite.instantiate.close_proposals_on_execution_failure = false; + let mut suite = suite.build(); + + suite.vote(suite.sender(), 1, vec![0, 1]).unwrap(); + // important that this errors the whole transaction to ensure that + // no state changes get committed. + suite.execute(suite.sender(), 1).unwrap_err(); +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/testing/suite.rs b/contracts/proposal/dao-proposal-condorcet/src/testing/suite.rs new file mode 100644 index 000000000..a9e7ac30a --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/testing/suite.rs @@ -0,0 +1,299 @@ +use cosmwasm_std::{coins, to_binary, Addr, BankMsg, CosmosMsg, Decimal}; +use cw_multi_test::{next_block, App, Executor}; +use cw_utils::Duration; +use dao_interface::{ + state::{Admin, ModuleInstantiateInfo}, + voting::InfoResponse, +}; +use dao_testing::contracts::{ + cw4_group_contract, dao_dao_contract, dao_voting_cw4_contract, proposal_condorcet_contract, +}; +use dao_voting::threshold::PercentageThreshold; +use dao_voting_cw4::msg::GroupContract; + +use crate::{ + config::{Config, UncheckedConfig}, + contract::{CONTRACT_NAME, CONTRACT_VERSION}, + msg::{Choice, ExecuteMsg, InstantiateMsg, QueryMsg}, + proposal::{ProposalResponse, Status}, + tally::Winner, +}; + +pub(crate) struct Suite { + app: App, + sender: Addr, + pub condorcet: Addr, + pub core: Addr, +} + +pub(crate) struct SuiteBuilder { + pub instantiate: InstantiateMsg, + with_proposal: Option, + with_voters: Vec<(String, u64)>, +} + +impl Default for SuiteBuilder { + fn default() -> Self { + Self { + instantiate: UncheckedConfig { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + voting_period: Duration::Time(60 * 60 * 24 * 7), + min_voting_period: Some(Duration::Time(60 * 60 * 24)), + close_proposals_on_execution_failure: true, + }, + with_proposal: None, + with_voters: vec![("sender".to_string(), 10)], + } + } +} + +impl SuiteBuilder { + #[allow(clippy::field_reassign_with_default)] + pub fn with_config(instantiate: UncheckedConfig) -> Self { + let mut b = Self::default(); + b.instantiate = instantiate; + b + } + + pub fn with_proposal(mut self, candidates: u32) -> Self { + self.with_proposal = Some(candidates); + self + } + + pub fn with_voters(mut self, voters: &[(&str, u64)]) -> Self { + self.with_voters = voters.iter().map(|(a, p)| (a.to_string(), *p)).collect(); + self + } + + pub fn build(self) -> Suite { + let initial_members: Vec<_> = self + .with_voters + .into_iter() + .map(|(addr, weight)| cw4::Member { addr, weight }) + .collect(); + let sender = Addr::unchecked(&initial_members[0].addr); + + let mut app = App::default(); + let condorcet_id = app.store_code(proposal_condorcet_contract()); + let core_id = app.store_code(dao_dao_contract()); + let cw4_id = app.store_code(cw4_group_contract()); + let cw4_voting_id = app.store_code(dao_voting_cw4_contract()); + + let core_instantiate = dao_interface::msg::InstantiateMsg { + admin: None, + name: "core module".to_string(), + description: "core module".to_string(), + image_url: Some("https://moonphase.is/image.svg".to_string()), + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: cw4_voting_id, + msg: to_binary(&dao_voting_cw4::msg::InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members, + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: condorcet_id, + msg: to_binary(&self.instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "condorcet module".to_string(), + }], + initial_items: None, + dao_uri: None, + }; + let core = app + .instantiate_contract( + core_id, + sender.clone(), + &core_instantiate, + &[], + "core module".to_string(), + None, + ) + .unwrap(); + let condorcet: Vec = app + .wrap() + .query_wasm_smart( + &core, + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + let condorcet = condorcet.into_iter().next().unwrap().address; + + app.update_block(next_block); + + let mut suite = Suite { + app, + sender, + condorcet, + core, + }; + + let next_id = suite.query_next_proposal_id(); + assert_eq!(next_id, 1); + + if let Some(candidates) = self.with_proposal { + suite + .propose( + &suite.sender(), + (0..candidates) + .map(|_| vec![unimportant_message()]) + .collect(), + ) + .unwrap(); + let next_id = suite.query_next_proposal_id(); + assert_eq!(next_id, 2); + } + + let dao = suite.query_dao(); + assert_eq!(dao, suite.core); + let info = suite.query_info(); + assert_eq!(info.info.version, CONTRACT_VERSION); + assert_eq!(info.info.contract, CONTRACT_NAME); + + suite + } +} + +impl Suite { + pub fn block_height(&self) -> u64 { + self.app.block_info().height + } + + pub fn a_day_passes(&mut self) { + self.app + .update_block(|b| b.time = b.time.plus_seconds(60 * 60 * 24)) + } + + pub fn a_week_passes(&mut self) { + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + self.a_day_passes(); + } + + pub fn sender(&self) -> Addr { + self.sender.clone() + } +} + +// query +impl Suite { + pub fn query_config(&self) -> Config { + self.app + .wrap() + .query_wasm_smart(&self.condorcet, &QueryMsg::Config {}) + .unwrap() + } + + pub fn query_proposal(&self, id: u32) -> ProposalResponse { + self.app + .wrap() + .query_wasm_smart(&self.condorcet, &QueryMsg::Proposal { id }) + .unwrap() + } + + pub fn query_winner_and_status(&self, id: u32) -> (Winner, Status) { + let q = self.query_proposal(id); + (q.tally.winner, q.proposal.last_status()) + } + + pub fn query_next_proposal_id(&self) -> u32 { + self.app + .wrap() + .query_wasm_smart(&self.condorcet, &QueryMsg::NextProposalId {}) + .unwrap() + } + + pub fn query_dao(&self) -> Addr { + self.app + .wrap() + .query_wasm_smart(&self.condorcet, &QueryMsg::Dao {}) + .unwrap() + } + + pub fn query_info(&self) -> InfoResponse { + self.app + .wrap() + .query_wasm_smart(&self.condorcet, &QueryMsg::Info {}) + .unwrap() + } +} + +// execute +impl Suite { + pub fn propose>( + &mut self, + sender: S, + choices: Vec>, + ) -> anyhow::Result { + let id = self.query_next_proposal_id(); + self.app.execute_contract( + Addr::unchecked(sender), + self.condorcet.clone(), + &ExecuteMsg::Propose { + choices: choices.into_iter().map(|msgs| Choice { msgs }).collect(), + }, + &[], + )?; + Ok(id) + } + + pub fn vote>( + &mut self, + sender: S, + proposal_id: u32, + vote: Vec, + ) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.condorcet.clone(), + &ExecuteMsg::Vote { proposal_id, vote }, + &[], + ) + .map(|_| ()) + } + + pub fn execute>(&mut self, sender: S, proposal_id: u32) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.condorcet.clone(), + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .map(|_| ()) + } + + pub fn close>(&mut self, sender: S, proposal_id: u32) -> anyhow::Result<()> { + self.app + .execute_contract( + Addr::unchecked(sender), + self.condorcet.clone(), + &ExecuteMsg::Close { proposal_id }, + &[], + ) + .map(|_| ()) + } +} + +pub fn unimportant_message() -> CosmosMsg { + BankMsg::Send { + to_address: "someone".to_string(), + amount: coins(10, "something"), + } + .into() +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/testing/tallying.rs b/contracts/proposal/dao-proposal-condorcet/src/testing/tallying.rs new file mode 100644 index 000000000..3e54154a4 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/testing/tallying.rs @@ -0,0 +1,153 @@ +use cosmwasm_std::Uint128; +use cw_utils::Expiration; + +use crate::{ + tally::{Tally, Winner}, + vote::Vote, +}; + +#[test] +fn test_pair_election() { + let candidates = 2; + let mut tally = Tally::new(candidates, Uint128::new(3), 0, Expiration::Never {}); + + tally.add_vote(Vote::new(vec![0, 1], candidates).unwrap(), Uint128::one()); + tally.add_vote(Vote::new(vec![1, 0], candidates).unwrap(), Uint128::one()); + tally.add_vote(Vote::new(vec![1, 0], candidates).unwrap(), Uint128::one()); + + assert_eq!(tally.winner, Winner::Undisputed(1)); +} + +#[test] +fn test_triplet_election() { + let candidates = 3; + let mut tally = Tally::new(candidates, Uint128::new(3), 0, Expiration::Never {}); + + tally.add_vote( + Vote::new(vec![0, 1, 2], candidates).unwrap(), + Uint128::one(), + ); + + assert_eq!(tally.winner, Winner::Some(0)); + + tally.add_vote( + Vote::new(vec![0, 2, 1], candidates).unwrap(), + Uint128::one(), + ); + tally.add_vote( + Vote::new(vec![2, 0, 1], candidates).unwrap(), + Uint128::one(), + ); + + assert_eq!(tally.winner, Winner::Undisputed(0)); +} + +#[test] +fn test_condorcet_paradox() { + let candidates = 3; + let mut tally = Tally::new(candidates, Uint128::new(6), 0, Expiration::Never {}); + + tally.add_vote( + Vote::new(vec![0, 2, 1], candidates).unwrap(), + Uint128::one(), + ); + tally.add_vote( + Vote::new(vec![1, 0, 2], candidates).unwrap(), + Uint128::one(), + ); + tally.add_vote( + Vote::new(vec![2, 1, 0], candidates).unwrap(), + Uint128::one(), + ); + tally.add_vote( + Vote::new(vec![1, 0, 2], candidates).unwrap(), + Uint128::one(), + ); + tally.add_vote( + Vote::new(vec![0, 2, 1], candidates).unwrap(), + Uint128::one(), + ); + tally.add_vote( + Vote::new(vec![2, 0, 1], candidates).unwrap(), + Uint128::one(), + ); + + // sequence of ballots cast: + // + // 0 > 2 > 1 + // 1 > 0 > 2 + // 2 > 1 > 0 + // 1 > 0 > 2 + // 0 > 2 > 1 + // 2 > 0 > 1 + // + // produces a M matrix: + // + // ``` + // \ 0 -2 + // 0 \ 2 + // 2 -2 \ + // ``` + // + // the "condorcet paradox" 0 > 2, 2 > 1, 0 !> 1. + assert_eq!(tally.winner, Winner::Never) +} + +#[test] +fn test_tally_overflow() { + let candidates = 6; + let mut tally = Tally::new(candidates, Uint128::MAX, 0, Expiration::Never {}); + + tally.add_vote( + Vote::new(vec![1, 2, 3, 4, 5, 0], candidates).unwrap(), + Uint128::new(u128::MAX / 2), + ); + tally.add_vote( + Vote::new(vec![2, 1, 3, 5, 0, 4], candidates).unwrap(), + Uint128::new(u128::MAX / 2 - 1), + ); + tally.add_vote( + Vote::new(vec![5, 0, 3, 1, 2, 4], candidates).unwrap(), + Uint128::one(), + ); + + assert_eq!(tally.winner, Winner::Undisputed(1)) +} + +#[test] +fn test_winner_none() { + let candidates = 6; + let mut tally = Tally::new(candidates, Uint128::new(9), 0, Expiration::Never {}); + + tally.add_vote( + Vote::new(vec![1, 2, 3, 4, 5, 0], candidates).unwrap(), + Uint128::new(2), + ); + + tally.add_vote( + Vote::new(vec![4, 5, 3, 0, 2, 1], candidates).unwrap(), + Uint128::new(2), + ); + + tally.add_vote( + Vote::new(vec![2, 3, 0, 5, 4, 1], candidates).unwrap(), + Uint128::new(1), + ); + + tally.add_vote( + Vote::new(vec![3, 0, 2, 4, 5, 1], candidates).unwrap(), + Uint128::new(1), + ); + + // at this point, there is no winner, but there is three voting + // power outstanding and 6 candidates so column 2, 3, etc. could + // be flipped so we can't declare the election over. + // + // \ -2 0 6 2 2 + // 2 \ 2 2 2 2 + // 0 -2 \ 0 -2 -2 + // -6 -2 0 \ -2 -2 + // -2 -2 2 2 \ -4 + // -2 -2 2 2 4 \ + assert_eq!(tally.winner, Winner::None) +} diff --git a/contracts/proposal/dao-proposal-condorcet/src/vote.rs b/contracts/proposal/dao-proposal-condorcet/src/vote.rs new file mode 100644 index 000000000..8e1cd3f79 --- /dev/null +++ b/contracts/proposal/dao-proposal-condorcet/src/vote.rs @@ -0,0 +1,83 @@ +use std::ops::Index; + +use cosmwasm_schema::cw_serde; +use thiserror::Error; + +#[cw_serde] +pub struct Vote(Vec); + +impl Vote { + pub(crate) fn new(vote: Vec, candidates: u32) -> Result { + if vote.len() != candidates as usize { + return Err(VoteError::LenMissmatch { + got: vote.len() as u32, + expected: candidates, + }); + } + let mut seen = vec![]; + for v in vote { + if v >= candidates { + return Err(VoteError::InvalidCandidate { candidate: v }); + } + if seen.contains(&v) { + return Err(VoteError::DuplicateCandidate { candidate: v }); + } + seen.push(v); + } + Ok(Vote(seen)) + } + + pub fn iter(&self) -> std::slice::Iter<'_, u32> { + self.0.iter() + } +} + +impl Index for Vote { + type Output = u32; + + fn index(&self, index: usize) -> &Self::Output { + self.0.index(index) + } +} + +#[derive(Error, Debug, PartialEq)] +pub enum VoteError { + #[error("candidate ({candidate}) appears in ballot more than once")] + DuplicateCandidate { candidate: u32 }, + + #[error("no such candidate ({candidate})")] + InvalidCandidate { candidate: u32 }, + + #[error("ballot has wrong number of candidates. got ({got}) expected ({expected})")] + LenMissmatch { got: u32, expected: u32 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vote_validation() { + assert_eq!( + Vote::new(vec![1, 2, 3, 0], 2).unwrap_err(), + VoteError::LenMissmatch { + got: 4, + expected: 2 + } + ); + assert_eq!( + Vote::new(vec![1, 2], 2).unwrap_err(), + VoteError::InvalidCandidate { candidate: 2 } + ); + assert_eq!( + Vote::new(vec![1, 1, 2, 2], 4).unwrap_err(), + VoteError::DuplicateCandidate { candidate: 1 } + ) + } + + #[test] + fn test_vote_construction() { + let vote = Vote::new(vec![0, 1, 2], 3).unwrap(); + assert_eq!(vote.0, vec![0, 1, 2]) + } +} diff --git a/contracts/proposal/dao-proposal-multiple/.cargo/config b/contracts/proposal/dao-proposal-multiple/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/proposal/dao-proposal-multiple/Cargo.toml b/contracts/proposal/dao-proposal-multiple/Cargo.toml new file mode 100644 index 000000000..35f200f8e --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "dao-proposal-multiple" +authors = ["blue-note"] +description = "A DAO DAO proposal module for multiple choice (a or b or c or ...) voting." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true, features = ["ibc3"] } +cosmwasm-storage = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw3 = { workspace = true } +thiserror = { workspace = true } +dao-dao-macros = { workspace = true } +dao-pre-propose-base = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +cw-hooks = { workspace = true } +dao-proposal-hooks = { workspace = true } +dao-vote-hooks = { workspace = true } +dao-pre-propose-multiple = { workspace = true } +voting-v1 = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting-cw20-balance = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-voting-native-staked = { workspace = true } +dao-voting-cw721-staked = { workspace = true } +cw-denom = { workspace = true } +dao-testing = { workspace = true } +cw20-stake = { workspace = true } +cw20-base = { workspace = true } +cw721-base = { workspace = true } +cw4 = { workspace = true } +cw4-group = { workspace = true } +rand = { workspace = true } diff --git a/contracts/proposal/dao-proposal-multiple/README.md b/contracts/proposal/dao-proposal-multiple/README.md new file mode 100644 index 000000000..72520af7e --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/README.md @@ -0,0 +1,51 @@ +## dao-proposal-multiple + +A proposal module for a DAO DAO DAO which allows the users to select +their voting choice(s) from an array of `MultipleChoiceOption`. +Each of the options may have associated messages which are to be +executed by the core module upon the proposal being passed and executed. + +Votes can be cast for as long as the proposal is not expired. In cases +where the proposal is no longer being evaluated (e.g. met the quorum and +been rejected), this allows voters to reflect their opinion even though +it has no effect on the final proposal's status. + +You can read more about this module in [our wiki](https://github.com/DA0-DA0/dao-contracts/wiki/Multiple-Choice-Proposal-Module). + +## Undesired behavior + +The undesired behavior of this contract is tested under `testing/adversarial_tests.rs`. + +In general, it should cover: +- Executing unpassed proposals +- Executing proposals more than once +- Social engineering proposals for financial benefit +- Convincing proposal modules to spend someone else's allowance + +## Proposal deposits + +Proposal deposits for this module are handled by the +[`dao-pre-propose-multiple`](../../pre-propose/dao-pre-propose-multiple) +contract. + +## Hooks + +This module supports hooks for voting and proposal status changes. One +may register a contract to receive these hooks with the `AddVoteHook` +and `AddProposalHook` methods. Upon registration the contract will +receive messages whenever a vote is cast and a proposal's status +changes (for example, when the proposal passes). + +The format for these hook messages can be located in the +`proposal-hooks` and `vote-hooks` packages located in +`packages/proposal-hooks` and `packages/vote-hooks` respectively. + +To stop an invalid hook receiver from locking the proposal module +receivers will be removed from the hook list if they error when +handling a hook. + +## Revoting + +The proposals may be configured to allow revoting. +In such cases, users are able to change their vote as long as the proposal is still open. +Revoting for the currently cast option will return an error. diff --git a/contracts/proposal/dao-proposal-multiple/examples/schema.rs b/contracts/proposal/dao-proposal-multiple/examples/schema.rs new file mode 100644 index 000000000..9352452e6 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_proposal_multiple::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json b/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json new file mode 100644 index 000000000..3783a86e8 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json @@ -0,0 +1,5793 @@ +{ + "contract_name": "dao-proposal-multiple", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "max_voting_period", + "only_members_execute", + "pre_propose_info", + "voting_strategy" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "max_voting_period": { + "description": "The amount of time a proposal can be voted on before expiring", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal.", + "type": "boolean" + }, + "pre_propose_info": { + "description": "Information about what addresses may create proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeInfo" + } + ] + }, + "voting_strategy": { + "description": "Voting params configuration", + "allOf": [ + { + "$ref": "#/definitions/VotingStrategy" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "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 + } + ] + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "VotingStrategy": { + "description": "Determines how many choices may be selected.", + "oneOf": [ + { + "type": "object", + "required": [ + "single_choice" + ], + "properties": { + "single_choice": { + "type": "object", + "required": [ + "quorum" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Creates a proposal in the governance module.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "type": "object", + "required": [ + "choices", + "description", + "title" + ], + "properties": { + "choices": { + "description": "The multiple choices.", + "allOf": [ + { + "$ref": "#/definitions/MultipleChoiceOptions" + } + ] + }, + "description": { + "description": "A description of the proposal.", + "type": "string" + }, + "proposer": { + "description": "The address creating the proposal. If no pre-propose module is attached to this module this must always be None as the proposer is the sender of the propose message. If a pre-propose module is attached, this must be Some and will set the proposer of the proposal it creates.", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "The title of the proposal.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Votes on a proposal. Voting power is determined by the DAO's voting power module.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to vote on.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "rationale": { + "description": "An optional rationale for why this vote was cast. This can be updated, set, or removed later by the address casting the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "The senders position on the proposal.", + "allOf": [ + { + "$ref": "#/definitions/MultipleChoiceVote" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Causes the messages associated with a passed proposal to be executed by the DAO.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to execute.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Closes a proposal that has failed (either not passed or timed out). If applicable this will cause the proposal deposit associated wth said proposal to be returned.", + "type": "object", + "required": [ + "close" + ], + "properties": { + "close": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to close.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the governance module's config.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "dao", + "max_voting_period", + "only_members_execute", + "voting_strategy" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "dao": { + "description": "The address if tge DAO that this governance module is associated with.", + "type": "string" + }, + "max_voting_period": { + "description": "The default maximum amount of time a proposal may be voted on before expiring. This will only apply to proposals created after the config update.", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal. Applies to all outstanding and future proposals.", + "type": "boolean" + }, + "voting_strategy": { + "description": "The new proposal voting strategy. This will only apply to proposals created after the config update.", + "allOf": [ + { + "$ref": "#/definitions/VotingStrategy" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the sender's rationale for their vote on the specified proposal. Errors if no vote vote has been cast.", + "type": "object", + "required": [ + "update_rationale" + ], + "properties": { + "update_rationale": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "rationale": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update's the proposal creation policy used for this module. Only the DAO may call this method.", + "type": "object", + "required": [ + "update_pre_propose_info" + ], + "properties": { + "update_pre_propose_info": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/PreProposeInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "add_proposal_hook" + ], + "properties": { + "add_proposal_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_proposal_hook" + ], + "properties": { + "remove_proposal_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "add_vote_hook" + ], + "properties": { + "add_vote_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_vote_hook" + ], + "properties": { + "remove_vote_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "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 + } + ] + }, + "Empty": { + "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" + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "MultipleChoiceOption": { + "description": "Unchecked multiple choice option", + "type": "object", + "required": [ + "description", + "msgs", + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, + "MultipleChoiceOptions": { + "description": "Represents unchecked multiple choice options", + "type": "object", + "required": [ + "options" + ], + "properties": { + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/MultipleChoiceOption" + } + } + }, + "additionalProperties": false + }, + "MultipleChoiceVote": { + "description": "A multiple choice vote, picking the desired option", + "type": "object", + "required": [ + "option_id" + ], + "properties": { + "option_id": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "VotingStrategy": { + "description": "Determines how many choices may be selected.", + "oneOf": [ + { + "type": "object", + "required": [ + "single_choice" + ], + "properties": { + "single_choice": { + "type": "object", + "required": [ + "quorum" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Gets the governance module's config.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets information about a proposal.", + "type": "object", + "required": [ + "proposal" + ], + "properties": { + "proposal": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all the proposals that have been cast in this module.", + "type": "object", + "required": [ + "list_proposals" + ], + "properties": { + "list_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the proposals that have been cast in this module in decending order of proposal ID.", + "type": "object", + "required": [ + "reverse_proposals" + ], + "properties": { + "reverse_proposals": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "start_before": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a voters position on a proposal.", + "type": "object", + "required": [ + "get_vote" + ], + "properties": { + "get_vote": { + "type": "object", + "required": [ + "proposal_id", + "voter" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "voter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the votes that have been cast on a proposal.", + "type": "object", + "required": [ + "list_votes" + ], + "properties": { + "list_votes": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the number of proposals that have been created in this module.", + "type": "object", + "required": [ + "proposal_count" + ], + "properties": { + "proposal_count": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the current proposal creation policy for this module.", + "type": "object", + "required": [ + "proposal_creation_policy" + ], + "properties": { + "proposal_creation_policy": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the consumers of proposal hooks for this module.", + "type": "object", + "required": [ + "proposal_hooks" + ], + "properties": { + "proposal_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the consumers of vote hooks for this module.", + "type": "object", + "required": [ + "vote_hooks" + ], + "properties": { + "vote_hooks": { + "type": "object", + "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 + }, + { + "description": "Returns the proposal ID that will be assigned to the next proposal created.", + "type": "object", + "required": [ + "next_proposal_id" + ], + "properties": { + "next_proposal_id": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "from_v1" + ], + "properties": { + "from_v1": { + "type": "object", + "required": [ + "close_proposal_on_execution_failure", + "pre_propose_info" + ], + "properties": { + "close_proposal_on_execution_failure": { + "description": "This field was not present in DAO DAO v1. To migrate, a value must be specified.\n\nIf set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "pre_propose_info": { + "description": "This field was not present in DAO DAO v1. To migrate, a value must be specified.\n\nThis contains information about how a pre-propose module may be configured. If set to \"AnyoneMayPropose\", there will be no pre-propose module and consequently, no deposit or membership checks when submitting a proposal. The \"ModuleMayPropose\" option allows for instantiating a prepropose module which will handle deposit verification and return logic.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeInfo" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "from_compatible" + ], + "properties": { + "from_compatible": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "The proposal module's configuration.", + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "dao", + "max_voting_period", + "only_members_execute", + "voting_strategy" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "dao": { + "description": "The address of the DAO that this governance module is associated with.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "max_voting_period": { + "description": "The default maximum amount of time a proposal may be voted on before expiring.", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal.", + "type": "boolean" + }, + "voting_strategy": { + "description": "The threshold a proposal must reach to complete.", + "allOf": [ + { + "$ref": "#/definitions/VotingStrategy" + } + ] + } + }, + "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" + }, + "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 + } + ] + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "VotingStrategy": { + "description": "Determines how many choices may be selected.", + "oneOf": [ + { + "type": "object", + "required": [ + "single_choice" + ], + "properties": { + "single_choice": { + "type": "object", + "required": [ + "quorum" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "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" + }, + "get_vote": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoteResponse", + "type": "object", + "properties": { + "vote": { + "anyOf": [ + { + "$ref": "#/definitions/VoteInfo" + }, + { + "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" + }, + "MultipleChoiceVote": { + "description": "A multiple choice vote, picking the desired option", + "type": "object", + "required": [ + "option_id" + ], + "properties": { + "option_id": { + "type": "integer", + "format": "uint32", + "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" + }, + "VoteInfo": { + "description": "Information about a vote that was cast.", + "type": "object", + "required": [ + "power", + "vote", + "voter" + ], + "properties": { + "power": { + "description": "The voting power behind the vote.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "rationale": { + "description": "The rationale behind the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "Position on the vote.", + "allOf": [ + { + "$ref": "#/definitions/MultipleChoiceVote" + } + ] + }, + "voter": { + "description": "The address that voted.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "additionalProperties": false, + "definitions": { + "ContractVersion": { + "type": "object", + "required": [ + "contract", + "version" + ], + "properties": { + "contract": { + "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", + "type": "string" + }, + "version": { + "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_proposals": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalListResponse", + "type": "object", + "required": [ + "proposals" + ], + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/definitions/ProposalResponse" + } + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "CheckedMultipleChoiceOption": { + "description": "A verified option that has all fields needed for voting.", + "type": "object", + "required": [ + "description", + "index", + "msgs", + "option_type", + "title", + "vote_count" + ], + "properties": { + "description": { + "type": "string" + }, + "index": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "option_type": { + "$ref": "#/definitions/MultipleChoiceOptionType" + }, + "title": { + "type": "string" + }, + "vote_count": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "MultipleChoiceOptionType": { + "description": "Represents the type of Multiple choice option. \"None of the above\" has a special type for example.", + "oneOf": [ + { + "type": "string", + "enum": [ + "standard" + ] + }, + { + "description": "Choice that represents selecting none of the options; still counts toward quorum and allows proposals with all bad options to be voted against.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "MultipleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "choices", + "description", + "expiration", + "proposer", + "start_height", + "status", + "title", + "total_power", + "votes", + "voting_strategy" + ], + "properties": { + "allow_revoting": { + "description": "Whether DAO members are allowed to change their votes. When disabled, proposals can be executed as soon as they pass. When enabled, proposals can only be executed after the voting perid has ended and the proposal passed.", + "type": "boolean" + }, + "choices": { + "description": "The options to be chosen from in the vote.", + "type": "array", + "items": { + "$ref": "#/definitions/CheckedMultipleChoiceOption" + } + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "description": "Prosal status (Open, rejected, executed, execution failed, closed, passed)", + "allOf": [ + { + "$ref": "#/definitions/Status" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total power when the proposal started (used to calculate percentages)", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "description": "The vote tally.", + "allOf": [ + { + "$ref": "#/definitions/MultipleChoiceVotes" + } + ] + }, + "voting_strategy": { + "description": "Voting settings (threshold, quorum, etc.)", + "allOf": [ + { + "$ref": "#/definitions/VotingStrategy" + } + ] + } + }, + "additionalProperties": false + }, + "MultipleChoiceVotes": { + "type": "object", + "required": [ + "vote_weights" + ], + "properties": { + "vote_weights": { + "type": "array", + "items": { + "$ref": "#/definitions/Uint128" + } + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "ProposalResponse": { + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/MultipleChoiceProposal" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "VotingStrategy": { + "description": "Determines how many choices may be selected.", + "oneOf": [ + { + "type": "object", + "required": [ + "single_choice" + ], + "properties": { + "single_choice": { + "type": "object", + "required": [ + "quorum" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "list_votes": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoteListResponse", + "type": "object", + "required": [ + "votes" + ], + "properties": { + "votes": { + "type": "array", + "items": { + "$ref": "#/definitions/VoteInfo" + } + } + }, + "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" + }, + "MultipleChoiceVote": { + "description": "A multiple choice vote, picking the desired option", + "type": "object", + "required": [ + "option_id" + ], + "properties": { + "option_id": { + "type": "integer", + "format": "uint32", + "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" + }, + "VoteInfo": { + "description": "Information about a vote that was cast.", + "type": "object", + "required": [ + "power", + "vote", + "voter" + ], + "properties": { + "power": { + "description": "The voting power behind the vote.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "rationale": { + "description": "The rationale behind the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "Position on the vote.", + "allOf": [ + { + "$ref": "#/definitions/MultipleChoiceVote" + } + ] + }, + "voter": { + "description": "The address that voted.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "next_proposal_id": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalResponse", + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/MultipleChoiceProposal" + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "CheckedMultipleChoiceOption": { + "description": "A verified option that has all fields needed for voting.", + "type": "object", + "required": [ + "description", + "index", + "msgs", + "option_type", + "title", + "vote_count" + ], + "properties": { + "description": { + "type": "string" + }, + "index": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "option_type": { + "$ref": "#/definitions/MultipleChoiceOptionType" + }, + "title": { + "type": "string" + }, + "vote_count": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "MultipleChoiceOptionType": { + "description": "Represents the type of Multiple choice option. \"None of the above\" has a special type for example.", + "oneOf": [ + { + "type": "string", + "enum": [ + "standard" + ] + }, + { + "description": "Choice that represents selecting none of the options; still counts toward quorum and allows proposals with all bad options to be voted against.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "MultipleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "choices", + "description", + "expiration", + "proposer", + "start_height", + "status", + "title", + "total_power", + "votes", + "voting_strategy" + ], + "properties": { + "allow_revoting": { + "description": "Whether DAO members are allowed to change their votes. When disabled, proposals can be executed as soon as they pass. When enabled, proposals can only be executed after the voting perid has ended and the proposal passed.", + "type": "boolean" + }, + "choices": { + "description": "The options to be chosen from in the vote.", + "type": "array", + "items": { + "$ref": "#/definitions/CheckedMultipleChoiceOption" + } + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "description": "Prosal status (Open, rejected, executed, execution failed, closed, passed)", + "allOf": [ + { + "$ref": "#/definitions/Status" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total power when the proposal started (used to calculate percentages)", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "description": "The vote tally.", + "allOf": [ + { + "$ref": "#/definitions/MultipleChoiceVotes" + } + ] + }, + "voting_strategy": { + "description": "Voting settings (threshold, quorum, etc.)", + "allOf": [ + { + "$ref": "#/definitions/VotingStrategy" + } + ] + } + }, + "additionalProperties": false + }, + "MultipleChoiceVotes": { + "type": "object", + "required": [ + "vote_weights" + ], + "properties": { + "vote_weights": { + "type": "array", + "items": { + "$ref": "#/definitions/Uint128" + } + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "VotingStrategy": { + "description": "Determines how many choices may be selected.", + "oneOf": [ + { + "type": "object", + "required": [ + "single_choice" + ], + "properties": { + "single_choice": { + "type": "object", + "required": [ + "quorum" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "proposal_count": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal_creation_policy": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalCreationPolicy", + "oneOf": [ + { + "description": "Anyone may create a proposal, free of charge.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only ADDR may create proposals. It is expected that ADDR is a pre-propose module, though we only require that it is a valid address.", + "type": "object", + "required": [ + "module" + ], + "properties": { + "module": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + }, + "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" + } + } + }, + "proposal_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "reverse_proposals": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalListResponse", + "type": "object", + "required": [ + "proposals" + ], + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/definitions/ProposalResponse" + } + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "CheckedMultipleChoiceOption": { + "description": "A verified option that has all fields needed for voting.", + "type": "object", + "required": [ + "description", + "index", + "msgs", + "option_type", + "title", + "vote_count" + ], + "properties": { + "description": { + "type": "string" + }, + "index": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "option_type": { + "$ref": "#/definitions/MultipleChoiceOptionType" + }, + "title": { + "type": "string" + }, + "vote_count": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "MultipleChoiceOptionType": { + "description": "Represents the type of Multiple choice option. \"None of the above\" has a special type for example.", + "oneOf": [ + { + "type": "string", + "enum": [ + "standard" + ] + }, + { + "description": "Choice that represents selecting none of the options; still counts toward quorum and allows proposals with all bad options to be voted against.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, + "MultipleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "choices", + "description", + "expiration", + "proposer", + "start_height", + "status", + "title", + "total_power", + "votes", + "voting_strategy" + ], + "properties": { + "allow_revoting": { + "description": "Whether DAO members are allowed to change their votes. When disabled, proposals can be executed as soon as they pass. When enabled, proposals can only be executed after the voting perid has ended and the proposal passed.", + "type": "boolean" + }, + "choices": { + "description": "The options to be chosen from in the vote.", + "type": "array", + "items": { + "$ref": "#/definitions/CheckedMultipleChoiceOption" + } + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "description": "Prosal status (Open, rejected, executed, execution failed, closed, passed)", + "allOf": [ + { + "$ref": "#/definitions/Status" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total power when the proposal started (used to calculate percentages)", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "description": "The vote tally.", + "allOf": [ + { + "$ref": "#/definitions/MultipleChoiceVotes" + } + ] + }, + "voting_strategy": { + "description": "Voting settings (threshold, quorum, etc.)", + "allOf": [ + { + "$ref": "#/definitions/VotingStrategy" + } + ] + } + }, + "additionalProperties": false + }, + "MultipleChoiceVotes": { + "type": "object", + "required": [ + "vote_weights" + ], + "properties": { + "vote_weights": { + "type": "array", + "items": { + "$ref": "#/definitions/Uint128" + } + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "ProposalResponse": { + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/MultipleChoiceProposal" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "VotingStrategy": { + "description": "Determines how many choices may be selected.", + "oneOf": [ + { + "type": "object", + "required": [ + "single_choice" + ], + "properties": { + "single_choice": { + "type": "object", + "required": [ + "quorum" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "vote_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/proposal/dao-proposal-multiple/src/contract.rs b/contracts/proposal/dao-proposal-multiple/src/contract.rs new file mode 100644 index 000000000..35465ab50 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/contract.rs @@ -0,0 +1,935 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Reply, Response, StdResult, + Storage, SubMsg, WasmMsg, +}; + +use cw2::set_contract_version; +use cw_hooks::Hooks; +use cw_storage_plus::Bound; +use cw_utils::{parse_reply_instantiate_data, Duration}; +use dao_interface::voting::IsActiveResponse; +use dao_pre_propose_multiple::contract::ExecuteMsg as PreProposeMsg; +use dao_proposal_hooks::{new_proposal_hooks, proposal_status_changed_hooks}; +use dao_vote_hooks::new_vote_hooks; +use dao_voting::{ + multiple_choice::{ + MultipleChoiceOptions, MultipleChoiceVote, MultipleChoiceVotes, VotingStrategy, + }, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + proposal::{DEFAULT_LIMIT, MAX_PROPOSAL_SIZE}, + reply::{ + failed_pre_propose_module_hook_id, mask_proposal_execution_proposal_id, TaggedReplyId, + }, + status::Status, + voting::{get_total_power, get_voting_power, validate_voting_period}, +}; + +use crate::{msg::MigrateMsg, state::CREATION_POLICY}; +use crate::{ + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + proposal::{MultipleChoiceProposal, VoteResult}, + query::{ProposalListResponse, ProposalResponse, VoteInfo, VoteListResponse, VoteResponse}, + state::{ + Ballot, Config, BALLOTS, CONFIG, PROPOSALS, PROPOSAL_COUNT, PROPOSAL_HOOKS, VOTE_HOOKS, + }, + ContractError, +}; + +pub const CONTRACT_NAME: &str = "crates.io:dao-proposal-multiple"; +pub 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)?; + + msg.voting_strategy.validate()?; + + let dao = info.sender; + + let (min_voting_period, max_voting_period) = + validate_voting_period(msg.min_voting_period, msg.max_voting_period)?; + + let (initial_policy, pre_propose_messages) = msg + .pre_propose_info + .into_initial_policy_and_messages(dao.clone())?; + + let config = Config { + voting_strategy: msg.voting_strategy, + min_voting_period, + max_voting_period, + only_members_execute: msg.only_members_execute, + allow_revoting: msg.allow_revoting, + dao, + close_proposal_on_execution_failure: msg.close_proposal_on_execution_failure, + }; + + // Initialize proposal count to zero so that queries return zero + // instead of None. + PROPOSAL_COUNT.save(deps.storage, &0)?; + CONFIG.save(deps.storage, &config)?; + CREATION_POLICY.save(deps.storage, &initial_policy)?; + + Ok(Response::default() + .add_submessages(pre_propose_messages) + .add_attribute("action", "instantiate") + .add_attribute("dao", config.dao)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::Propose { + title, + description, + choices, + proposer, + } => execute_propose( + deps, + env, + info.sender, + title, + description, + choices, + proposer, + ), + ExecuteMsg::Vote { + proposal_id, + vote, + rationale, + } => execute_vote(deps, env, info, proposal_id, vote, rationale), + ExecuteMsg::Execute { proposal_id } => execute_execute(deps, env, info, proposal_id), + ExecuteMsg::Close { proposal_id } => execute_close(deps, env, info, proposal_id), + ExecuteMsg::UpdateConfig { + voting_strategy, + min_voting_period, + max_voting_period, + only_members_execute, + allow_revoting, + dao, + close_proposal_on_execution_failure, + } => execute_update_config( + deps, + info, + voting_strategy, + min_voting_period, + max_voting_period, + only_members_execute, + allow_revoting, + dao, + close_proposal_on_execution_failure, + ), + ExecuteMsg::UpdatePreProposeInfo { info: new_info } => { + execute_update_proposal_creation_policy(deps, info, new_info) + } + ExecuteMsg::AddProposalHook { address } => { + execute_add_proposal_hook(deps, env, info, address) + } + ExecuteMsg::RemoveProposalHook { address } => { + execute_remove_proposal_hook(deps, env, info, address) + } + ExecuteMsg::AddVoteHook { address } => execute_add_vote_hook(deps, env, info, address), + ExecuteMsg::RemoveVoteHook { address } => { + execute_remove_vote_hook(deps, env, info, address) + } + ExecuteMsg::UpdateRationale { + proposal_id, + rationale, + } => execute_update_rationale(deps, info, proposal_id, rationale), + } +} + +pub fn execute_propose( + deps: DepsMut, + env: Env, + sender: Addr, + title: String, + description: String, + options: MultipleChoiceOptions, + proposer: Option, +) -> Result, ContractError> { + let config = CONFIG.load(deps.storage)?; + let proposal_creation_policy = CREATION_POLICY.load(deps.storage)?; + + // Check that the sender is permitted to create proposals. + if !proposal_creation_policy.is_permitted(&sender) { + return Err(ContractError::Unauthorized {}); + } + + // Determine the appropriate proposer. If this is coming from our + // pre-propose module, it must be specified. Otherwise, the + // proposer should not be specified. + let proposer = match (proposer, &proposal_creation_policy) { + (None, ProposalCreationPolicy::Anyone {}) => sender.clone(), + // `is_permitted` above checks that an allowed module is + // actually sending the propose message. + (Some(proposer), ProposalCreationPolicy::Module { .. }) => { + deps.api.addr_validate(&proposer)? + } + _ => return Err(ContractError::InvalidProposer {}), + }; + + let voting_module: Addr = deps.querier.query_wasm_smart( + config.dao.clone(), + &dao_interface::msg::QueryMsg::VotingModule {}, + )?; + + // Voting modules are not required to implement this + // query. Lacking an implementation they are active by default. + let active_resp: IsActiveResponse = deps + .querier + .query_wasm_smart(voting_module, &dao_interface::voting::Query::IsActive {}) + .unwrap_or(IsActiveResponse { active: true }); + + if !active_resp.active { + return Err(ContractError::InactiveDao {}); + } + + // Validate options. + let checked_multiple_choice_options = options.into_checked()?.options; + + let expiration = config.max_voting_period.after(&env.block); + let total_power = get_total_power(deps.as_ref(), &config.dao, None)?; + + let proposal = { + // Limit mutability to this block. + let mut proposal = MultipleChoiceProposal { + title, + description, + proposer: proposer.clone(), + start_height: env.block.height, + min_voting_period: config.min_voting_period.map(|min| min.after(&env.block)), + expiration, + voting_strategy: config.voting_strategy, + total_power, + status: Status::Open, + votes: MultipleChoiceVotes::zero(checked_multiple_choice_options.len()), + allow_revoting: config.allow_revoting, + choices: checked_multiple_choice_options, + }; + // Update the proposal's status. Addresses case where proposal + // expires on the same block as it is created. + proposal.update_status(&env.block)?; + proposal + }; + let id = advance_proposal_id(deps.storage)?; + + // Limit the size of proposals. + // + // The Juno mainnet has a larger limit for data that can be + // uploaded as part of an execute message than it does for data + // that can be queried as part of a query. This means that without + // this check it is possible to create a proposal that can not be + // queried. + // + // The size selected was determined by uploading versions of this + // contract to the Juno mainnet until queries worked within a + // reasonable margin of error. + // + // `to_vec` is the method used by cosmwasm to convert a struct + // into it's byte representation in storage. + let proposal_size = cosmwasm_std::to_vec(&proposal)?.len() as u64; + if proposal_size > MAX_PROPOSAL_SIZE { + return Err(ContractError::ProposalTooLarge { + size: proposal_size, + max: MAX_PROPOSAL_SIZE, + }); + } + + PROPOSALS.save(deps.storage, id, &proposal)?; + + let hooks = new_proposal_hooks(PROPOSAL_HOOKS, deps.storage, id, proposer.as_str())?; + + Ok(Response::default() + .add_submessages(hooks) + .add_attribute("action", "propose") + .add_attribute("sender", sender) + .add_attribute("proposal_id", id.to_string()) + .add_attribute("status", proposal.status.to_string())) +} + +pub fn execute_vote( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, + vote: MultipleChoiceVote, + rationale: Option, +) -> Result, ContractError> { + let config = CONFIG.load(deps.storage)?; + let mut prop = PROPOSALS + .may_load(deps.storage, proposal_id)? + .ok_or(ContractError::NoSuchProposal { id: proposal_id })?; + + // Check that this is a valid vote. + if vote.option_id as usize >= prop.choices.len() { + return Err(ContractError::InvalidVote {}); + } + + // Allow voting on proposals until they expire. + // Voting on a non-open proposal will never change + // their outcome as if an outcome has been determined, + // it is because no possible sequence of votes may + // cause a different one. This then serves to allow + // for better tallies of opinions in the event that a + // proposal passes or is rejected early. + if prop.expiration.is_expired(&env.block) { + return Err(ContractError::Expired { id: proposal_id }); + } + + let vote_power = get_voting_power( + deps.as_ref(), + info.sender.clone(), + &config.dao, + Some(prop.start_height), + )?; + if vote_power.is_zero() { + return Err(ContractError::NotRegistered {}); + } + + BALLOTS.update(deps.storage, (proposal_id, &info.sender), |bal| match bal { + Some(current_ballot) => { + if prop.allow_revoting { + if current_ballot.vote == vote { + // Don't allow casting the same vote more than + // once. This seems liable to be confusing + // behavior. + Err(ContractError::AlreadyCast {}) + } else { + // Remove the old vote if this is a re-vote. + prop.votes + .remove_vote(current_ballot.vote, current_ballot.power)?; + Ok(Ballot { + power: vote_power, + vote, + rationale, + }) + } + } else { + Err(ContractError::AlreadyVoted {}) + } + } + None => Ok(Ballot { + vote, + power: vote_power, + rationale, + }), + })?; + + let old_status = prop.status; + + prop.votes.add_vote(vote, vote_power)?; + prop.update_status(&env.block)?; + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + let new_status = prop.status; + let change_hooks = proposal_status_changed_hooks( + PROPOSAL_HOOKS, + deps.storage, + proposal_id, + old_status.to_string(), + new_status.to_string(), + )?; + let vote_hooks = new_vote_hooks( + VOTE_HOOKS, + deps.storage, + proposal_id, + info.sender.to_string(), + vote.to_string(), + )?; + Ok(Response::default() + .add_submessages(change_hooks) + .add_submessages(vote_hooks) + .add_attribute("action", "vote") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("position", vote.to_string()) + .add_attribute("status", prop.status.to_string())) +} + +pub fn execute_execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, +) -> Result { + let mut prop = PROPOSALS + .may_load(deps.storage, proposal_id)? + .ok_or(ContractError::NoSuchProposal { id: proposal_id })?; + + let config = CONFIG.load(deps.storage)?; + if config.only_members_execute { + let power = get_voting_power( + deps.as_ref(), + info.sender.clone(), + &config.dao, + Some(prop.start_height), + )?; + if power.is_zero() { + return Err(ContractError::Unauthorized {}); + } + } + + // Check here that the proposal is passed. Allow it to be + // executed even if it is expired so long as it passed during its + // voting period. + prop.update_status(&env.block)?; + let old_status = prop.status; + if prop.status != Status::Passed { + return Err(ContractError::NotPassed {}); + } + + prop.status = Status::Executed; + + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + let vote_result = prop.calculate_vote_result()?; + match vote_result { + VoteResult::Tie => Err(ContractError::Tie {}), // We don't anticipate this case as the proposal would not be in passed state, checked above. + VoteResult::SingleWinner(winning_choice) => { + let response = if !winning_choice.msgs.is_empty() { + let execute_message = WasmMsg::Execute { + contract_addr: config.dao.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::ExecuteProposalHook { + msgs: winning_choice.msgs, + })?, + funds: vec![], + }; + match config.close_proposal_on_execution_failure { + true => { + let masked_proposal_id = mask_proposal_execution_proposal_id(proposal_id); + Response::default().add_submessage(SubMsg::reply_on_error( + execute_message, + masked_proposal_id, + )) + } + false => Response::default().add_message(execute_message), + } + } else { + Response::default() + }; + + let hooks = proposal_status_changed_hooks( + PROPOSAL_HOOKS, + deps.storage, + proposal_id, + old_status.to_string(), + prop.status.to_string(), + )?; + + // Add prepropose / deposit module hook which will handle deposit refunds. + let proposal_creation_policy = CREATION_POLICY.load(deps.storage)?; + let hooks = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => hooks, + ProposalCreationPolicy::Module { addr } => { + let msg = to_binary(&PreProposeMsg::ProposalCompletedHook { + proposal_id, + new_status: prop.status, + })?; + let mut hooks = hooks; + hooks.push(SubMsg::reply_on_error( + WasmMsg::Execute { + contract_addr: addr.into_string(), + msg, + funds: vec![], + }, + failed_pre_propose_module_hook_id(), + )); + hooks + } + }; + + Ok(response + .add_submessages(hooks) + .add_attribute("action", "execute") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("dao", config.dao)) + } + } +} + +pub fn execute_close( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, +) -> Result, ContractError> { + let mut prop = PROPOSALS.load(deps.storage, proposal_id)?; + + prop.update_status(&env.block)?; + if prop.status != Status::Rejected { + return Err(ContractError::WrongCloseStatus {}); + } + + let old_status = prop.status; + + prop.status = Status::Closed; + + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + let hooks = proposal_status_changed_hooks( + PROPOSAL_HOOKS, + deps.storage, + proposal_id, + old_status.to_string(), + prop.status.to_string(), + )?; + + // Add prepropose / deposit module hook which will handle deposit refunds. + let proposal_creation_policy = CREATION_POLICY.load(deps.storage)?; + let hooks = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => hooks, + ProposalCreationPolicy::Module { addr } => { + let msg = to_binary(&PreProposeMsg::ProposalCompletedHook { + proposal_id, + new_status: prop.status, + })?; + let mut hooks = hooks; + hooks.push(SubMsg::reply_on_error( + WasmMsg::Execute { + contract_addr: addr.into_string(), + msg, + funds: vec![], + }, + failed_pre_propose_module_hook_id(), + )); + hooks + } + }; + Ok(Response::default() + .add_submessages(hooks) + .add_attribute("action", "close") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string())) +} + +#[allow(clippy::too_many_arguments)] +pub fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + voting_strategy: VotingStrategy, + min_voting_period: Option, + max_voting_period: Duration, + only_members_execute: bool, + allow_revoting: bool, + dao: String, + close_proposal_on_execution_failure: bool, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Only the DAO may call this method. + if info.sender != config.dao { + return Err(ContractError::Unauthorized {}); + } + + voting_strategy.validate()?; + + let dao = deps.api.addr_validate(&dao)?; + + let (min_voting_period, max_voting_period) = + validate_voting_period(min_voting_period, max_voting_period)?; + + CONFIG.save( + deps.storage, + &Config { + voting_strategy, + min_voting_period, + max_voting_period, + only_members_execute, + allow_revoting, + dao, + close_proposal_on_execution_failure, + }, + )?; + + Ok(Response::default() + .add_attribute("action", "update_config") + .add_attribute("sender", info.sender)) +} + +pub fn execute_update_proposal_creation_policy( + deps: DepsMut, + info: MessageInfo, + new_info: PreProposeInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + return Err(ContractError::Unauthorized {}); + } + + let (initial_policy, messages) = new_info.into_initial_policy_and_messages(config.dao)?; + CREATION_POLICY.save(deps.storage, &initial_policy)?; + + Ok(Response::default() + .add_submessages(messages) + .add_attribute("action", "update_proposal_creation_policy") + .add_attribute("sender", info.sender) + .add_attribute("new_policy", format!("{initial_policy:?}"))) +} + +pub fn execute_update_rationale( + deps: DepsMut, + info: MessageInfo, + proposal_id: u64, + rationale: Option, +) -> Result { + BALLOTS.update( + deps.storage, + // info.sender can't be forged so we implicitly access control + // with the key. + (proposal_id, &info.sender), + |ballot| match ballot { + Some(ballot) => Ok(Ballot { + rationale: rationale.clone(), + ..ballot + }), + None => Err(ContractError::NoSuchVote { + id: proposal_id, + voter: info.sender.to_string(), + }), + }, + )?; + + Ok(Response::default() + .add_attribute("action", "update_rationale") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("rationale", rationale.as_deref().unwrap_or("none"))) +} + +pub fn execute_add_proposal_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + address: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + // Only DAO can add hooks + return Err(ContractError::Unauthorized {}); + } + + let validated_address = deps.api.addr_validate(&address)?; + + add_hook(PROPOSAL_HOOKS, deps.storage, validated_address)?; + + Ok(Response::default() + .add_attribute("action", "add_proposal_hook") + .add_attribute("address", address)) +} + +pub fn execute_remove_proposal_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + address: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + // Only DAO can remove hooks + return Err(ContractError::Unauthorized {}); + } + + let validated_address = deps.api.addr_validate(&address)?; + + remove_hook(PROPOSAL_HOOKS, deps.storage, validated_address)?; + + Ok(Response::default() + .add_attribute("action", "remove_proposal_hook") + .add_attribute("address", address)) +} + +pub fn execute_add_vote_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + address: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + // Only DAO can add hooks + return Err(ContractError::Unauthorized {}); + } + + let validated_address = deps.api.addr_validate(&address)?; + + add_hook(VOTE_HOOKS, deps.storage, validated_address)?; + + Ok(Response::default() + .add_attribute("action", "add_vote_hook") + .add_attribute("address", address)) +} + +pub fn execute_remove_vote_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + address: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + // Only DAO can remove hooks + return Err(ContractError::Unauthorized {}); + } + + let validated_address = deps.api.addr_validate(&address)?; + + remove_hook(VOTE_HOOKS, deps.storage, validated_address)?; + + Ok(Response::default() + .add_attribute("action", "remove_vote_hook") + .add_attribute("address", address)) +} + +pub fn add_hook( + hooks: Hooks, + storage: &mut dyn Storage, + validated_address: Addr, +) -> Result<(), ContractError> { + hooks + .add_hook(storage, validated_address) + .map_err(ContractError::HookError)?; + Ok(()) +} + +pub fn remove_hook( + hooks: Hooks, + storage: &mut dyn Storage, + validate_address: Addr, +) -> Result<(), ContractError> { + hooks + .remove_hook(storage, validate_address) + .map_err(ContractError::HookError)?; + Ok(()) +} + +pub fn next_proposal_id(store: &dyn Storage) -> StdResult { + Ok(PROPOSAL_COUNT.may_load(store)?.unwrap_or_default() + 1) +} + +pub fn advance_proposal_id(store: &mut dyn Storage) -> StdResult { + let id: u64 = next_proposal_id(store)?; + PROPOSAL_COUNT.save(store, &id)?; + Ok(id) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => query_config(deps), + QueryMsg::Proposal { proposal_id } => query_proposal(deps, env, proposal_id), + QueryMsg::ListProposals { start_after, limit } => { + query_list_proposals(deps, env, start_after, limit) + } + QueryMsg::NextProposalId {} => query_next_proposal_id(deps), + QueryMsg::ProposalCount {} => query_proposal_count(deps), + QueryMsg::GetVote { proposal_id, voter } => query_vote(deps, proposal_id, voter), + QueryMsg::ListVotes { + proposal_id, + start_after, + limit, + } => query_list_votes(deps, proposal_id, start_after, limit), + QueryMsg::Info {} => query_info(deps), + QueryMsg::ReverseProposals { + start_before, + limit, + } => query_reverse_proposals(deps, env, start_before, limit), + QueryMsg::ProposalCreationPolicy {} => query_creation_policy(deps), + QueryMsg::ProposalHooks {} => to_binary(&PROPOSAL_HOOKS.query_hooks(deps)?), + QueryMsg::VoteHooks {} => to_binary(&VOTE_HOOKS.query_hooks(deps)?), + QueryMsg::Dao {} => query_dao(deps), + } +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&config) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&config.dao) +} + +pub fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult { + let proposal = PROPOSALS.load(deps.storage, id)?; + to_binary(&proposal.into_response(&env.block, id)?) +} + +pub fn query_creation_policy(deps: Deps) -> StdResult { + let policy = CREATION_POLICY.load(deps.storage)?; + to_binary(&policy) +} + +pub fn query_list_proposals( + deps: Deps, + env: Env, + start_after: Option, + limit: Option, +) -> StdResult { + let min = start_after.map(Bound::exclusive); + let limit = limit.unwrap_or(DEFAULT_LIMIT); + let props: Vec = PROPOSALS + .range(deps.storage, min, None, cosmwasm_std::Order::Ascending) + .take(limit as usize) + .collect::, _>>()? + .into_iter() + .map(|(id, proposal)| proposal.into_response(&env.block, id)) + .collect::>>()?; + + to_binary(&ProposalListResponse { proposals: props }) +} + +pub fn query_reverse_proposals( + deps: Deps, + env: Env, + start_before: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT); + let max = start_before.map(Bound::exclusive); + let props: Vec = PROPOSALS + .range(deps.storage, None, max, cosmwasm_std::Order::Descending) + .take(limit as usize) + .collect::, _>>()? + .into_iter() + .map(|(id, proposal)| proposal.into_response(&env.block, id)) + .collect::>>()?; + + to_binary(&ProposalListResponse { proposals: props }) +} + +pub fn query_next_proposal_id(deps: Deps) -> StdResult { + to_binary(&next_proposal_id(deps.storage)?) +} + +pub fn query_proposal_count(deps: Deps) -> StdResult { + let proposal_count = PROPOSAL_COUNT.load(deps.storage)?; + to_binary(&proposal_count) +} + +pub fn query_vote(deps: Deps, proposal_id: u64, voter: String) -> StdResult { + let voter = deps.api.addr_validate(&voter)?; + let ballot = BALLOTS.may_load(deps.storage, (proposal_id, &voter))?; + let vote = ballot.map(|ballot| VoteInfo { + voter, + vote: ballot.vote, + power: ballot.power, + rationale: ballot.rationale, + }); + to_binary(&VoteResponse { vote }) +} + +pub fn query_list_votes( + deps: Deps, + proposal_id: u64, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT); + let start_after = start_after + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + let min = start_after.as_ref().map(Bound::<&Addr>::exclusive); + + let votes = BALLOTS + .prefix(proposal_id) + .range(deps.storage, min, None, cosmwasm_std::Order::Ascending) + .take(limit as usize) + .map(|item| { + let (voter, ballot) = item?; + Ok(VoteInfo { + voter, + vote: ballot.vote, + power: ballot.power, + rationale: ballot.rationale, + }) + }) + .collect::>>()?; + + to_binary(&VoteListResponse { votes }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + let repl = TaggedReplyId::new(msg.id)?; + match repl { + TaggedReplyId::FailedProposalExecution(proposal_id) => { + PROPOSALS.update(deps.storage, proposal_id, |prop| match prop { + Some(mut prop) => { + prop.status = Status::ExecutionFailed; + Ok(prop) + } + None => Err(ContractError::NoSuchProposal { id: proposal_id }), + })?; + Ok(Response::new().add_attribute("proposal execution failed", proposal_id.to_string())) + } + TaggedReplyId::FailedProposalHook(idx) => { + let addr = PROPOSAL_HOOKS.remove_hook_by_index(deps.storage, idx)?; + Ok(Response::new().add_attribute("removed_proposal_hook", format!("{addr}:{idx}"))) + } + TaggedReplyId::FailedVoteHook(idx) => { + let addr = VOTE_HOOKS.remove_hook_by_index(deps.storage, idx)?; + Ok(Response::new().add_attribute("removed vote hook", format!("{addr}:{idx}"))) + } + TaggedReplyId::PreProposeModuleInstantiation => { + let res = parse_reply_instantiate_data(msg)?; + let module = deps.api.addr_validate(&res.contract_address)?; + CREATION_POLICY.save( + deps.storage, + &ProposalCreationPolicy::Module { addr: module }, + )?; + + match res.data { + Some(data) => Ok(Response::new() + .add_attribute("update_pre_propose_module", res.contract_address) + .set_data(data)), + None => Ok(Response::new() + .add_attribute("update_pre_propose_module", res.contract_address)), + } + } + TaggedReplyId::FailedPreProposeModuleHook => { + let addr = match CREATION_POLICY.load(deps.storage)? { + ProposalCreationPolicy::Anyone {} => { + // Something is off if we're getting this + // reply and we don't have a pre-propose + // module installed. This should be + // unreachable. + return Err(ContractError::InvalidReplyID { + id: failed_pre_propose_module_hook_id(), + }); + } + ProposalCreationPolicy::Module { addr } => { + // If we are here, our pre-propose module has + // errored while receiving a proposal + // hook. Rest in peace pre-propose module. + CREATION_POLICY.save(deps.storage, &ProposalCreationPolicy::Anyone {})?; + addr + } + }; + Ok(Response::new().add_attribute("failed_prepropose_hook", format!("{addr}"))) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/proposal/dao-proposal-multiple/src/error.rs b/contracts/proposal/dao-proposal-multiple/src/error.rs new file mode 100644 index 000000000..a1d1df105 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/error.rs @@ -0,0 +1,101 @@ +use std::u64; + +use cosmwasm_std::StdError; +use cw_hooks::HookError; +use cw_utils::ParseReplyError; +use dao_voting::{reply::error::TagError, threshold::ThresholdError}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error(transparent)] + ParseReplyError(#[from] ParseReplyError), + + #[error("{0}")] + HookError(#[from] HookError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("{0}")] + ThresholdError(#[from] ThresholdError), + + #[error("{0}")] + VotingError(#[from] dao_voting::error::VotingError), + + #[error("Suggested proposal expiration is larger than the maximum proposal duration")] + InvalidExpiration {}, + + #[error("No such proposal ({id})")] + NoSuchProposal { id: u64 }, + + #[error("Proposal is ({size}) bytes, must be <= ({max}) bytes")] + ProposalTooLarge { size: u64, max: u64 }, + + #[error("Proposal ({id}) is expired")] + Expired { id: u64 }, + + #[error("Not registered to vote (no voting power) at time of proposal creation.")] + NotRegistered {}, + + #[error("No vote exists for proposal ({id}) and voter ({voter})")] + NoSuchVote { id: u64, voter: String }, + + #[error("Already voted. This proposal does not support revoting.")] + AlreadyVoted {}, + + #[error("Already cast a vote with that option. Change your vote to revote.")] + AlreadyCast {}, + + #[error("Proposal must be in 'passed' state to be executed.")] + NotPassed {}, + + #[error("Proposal is in a tie: two or more options have the same number of votes.")] + Tie {}, + + #[error("Proposal is not expired.")] + NotExpired {}, + + #[error("Only rejected proposals may be closed.")] + WrongCloseStatus {}, + + #[error("The DAO is currently inactive, you cannot create proposals.")] + InactiveDao {}, + + #[error("Proposal must have at least two choices.")] + WrongNumberOfChoices {}, + + #[error("Must have exactly one 'none of the above' option.")] + NoneOption {}, + + #[error("No vote weights found.")] + NoVoteWeights {}, + + #[error("Invalid vote selected.")] + InvalidVote {}, + + #[error("Must have voting power to propose.")] + MustHaveVotingPower {}, + + #[error( + "pre-propose modules must specify a proposer. lacking one, no proposer should be specified" + )] + InvalidProposer {}, + + #[error("{0}")] + Tag(#[from] TagError), + + #[error( + "all proposals with deposits must be completed out (closed or executed) before migration" + )] + PendingProposals {}, + + #[error("received a failed proposal hook reply with an invalid hook index: ({idx})")] + InvalidHookIndex { idx: u64 }, + + #[error("received a reply failure with an invalid ID: ({id})")] + InvalidReplyID { id: u64 }, +} diff --git a/contracts/proposal/dao-proposal-multiple/src/lib.rs b/contracts/proposal/dao-proposal-multiple/src/lib.rs new file mode 100644 index 000000000..9841649c8 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/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; +pub mod proposal; +pub mod query; +pub mod state; +pub use crate::error::ContractError; + +#[cfg(test)] +pub mod testing; diff --git a/contracts/proposal/dao-proposal-multiple/src/msg.rs b/contracts/proposal/dao-proposal-multiple/src/msg.rs new file mode 100644 index 000000000..a79fca805 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/msg.rs @@ -0,0 +1,222 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cw_utils::Duration; +use dao_dao_macros::proposal_module_query; +use dao_voting::{ + multiple_choice::{MultipleChoiceOptions, MultipleChoiceVote, VotingStrategy}, + pre_propose::PreProposeInfo, +}; + +#[cw_serde] +pub struct InstantiateMsg { + /// Voting params configuration + pub voting_strategy: VotingStrategy, + /// The minimum amount of time a proposal must be open before + /// passing. A proposal may fail before this amount of time has + /// elapsed, but it will not pass. This can be useful for + /// preventing governance attacks wherein an attacker aquires a + /// large number of tokens and forces a proposal through. + pub min_voting_period: Option, + /// The amount of time a proposal can be voted on before expiring + pub max_voting_period: Duration, + /// If set to true only members may execute passed + /// proposals. Otherwise, any address may execute a passed + /// proposal. + pub only_members_execute: bool, + /// Allows changing votes before the proposal expires. If this is + /// enabled proposals will not be able to complete early as final + /// vote information is not known until the time of proposal + /// expiration. + pub allow_revoting: bool, + /// Information about what addresses may create proposals. + pub pre_propose_info: PreProposeInfo, + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + pub close_proposal_on_execution_failure: bool, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Creates a proposal in the governance module. + Propose { + /// The title of the proposal. + title: String, + /// A description of the proposal. + description: String, + /// The multiple choices. + choices: MultipleChoiceOptions, + /// The address creating the proposal. If no pre-propose + /// module is attached to this module this must always be None + /// as the proposer is the sender of the propose message. If a + /// pre-propose module is attached, this must be Some and will + /// set the proposer of the proposal it creates. + proposer: Option, + }, + /// Votes on a proposal. Voting power is determined by the DAO's + /// voting power module. + Vote { + /// The ID of the proposal to vote on. + proposal_id: u64, + /// The senders position on the proposal. + vote: MultipleChoiceVote, + /// An optional rationale for why this vote was cast. This can + /// be updated, set, or removed later by the address casting + /// the vote. + rationale: Option, + }, + /// Causes the messages associated with a passed proposal to be + /// executed by the DAO. + Execute { + /// The ID of the proposal to execute. + proposal_id: u64, + }, + /// Closes a proposal that has failed (either not passed or timed + /// out). If applicable this will cause the proposal deposit + /// associated wth said proposal to be returned. + Close { + /// The ID of the proposal to close. + proposal_id: u64, + }, + /// Updates the governance module's config. + UpdateConfig { + /// The new proposal voting strategy. This will only apply + /// to proposals created after the config update. + voting_strategy: VotingStrategy, + /// The minimum amount of time a proposal must be open before + /// passing. A proposal may fail before this amount of time has + /// elapsed, but it will not pass. This can be useful for + /// preventing governance attacks wherein an attacker aquires a + /// large number of tokens and forces a proposal through. + min_voting_period: Option, + /// The default maximum amount of time a proposal may be voted + /// on before expiring. This will only apply to proposals + /// created after the config update. + max_voting_period: Duration, + /// If set to true only members may execute passed + /// proposals. Otherwise, any address may execute a passed + /// proposal. Applies to all outstanding and future proposals. + only_members_execute: bool, + /// Allows changing votes before the proposal expires. If this is + /// enabled proposals will not be able to complete early as final + /// vote information is not known until the time of proposal + /// expiration. + allow_revoting: bool, + /// The address if tge DAO that this governance module is + /// associated with. + dao: String, + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + close_proposal_on_execution_failure: bool, + }, + /// Updates the sender's rationale for their vote on the specified + /// proposal. Errors if no vote vote has been cast. + UpdateRationale { + proposal_id: u64, + rationale: Option, + }, + /// Update's the proposal creation policy used for this + /// module. Only the DAO may call this method. + UpdatePreProposeInfo { + info: PreProposeInfo, + }, + AddProposalHook { + address: String, + }, + RemoveProposalHook { + address: String, + }, + AddVoteHook { + address: String, + }, + RemoveVoteHook { + address: String, + }, +} + +#[proposal_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Gets the governance module's config. + #[returns(crate::state::Config)] + Config {}, + /// Gets information about a proposal. + #[returns(crate::query::ProposalResponse)] + Proposal { proposal_id: u64 }, + /// Lists all the proposals that have been cast in this module. + #[returns(crate::query::ProposalListResponse)] + ListProposals { + start_after: Option, + limit: Option, + }, + /// Lists all of the proposals that have been cast in this module + /// in decending order of proposal ID. + #[returns(crate::query::ProposalListResponse)] + ReverseProposals { + start_before: Option, + limit: Option, + }, + /// Returns a voters position on a proposal. + #[returns(crate::query::VoteResponse)] + GetVote { proposal_id: u64, voter: String }, + /// Lists all of the votes that have been cast on a proposal. + #[returns(crate::query::VoteListResponse)] + ListVotes { + proposal_id: u64, + start_after: Option, + limit: Option, + }, + /// Returns the number of proposals that have been created in this module. + #[returns(::std::primitive::u64)] + ProposalCount {}, + /// Gets the current proposal creation policy for this module. + #[returns(::dao_voting::pre_propose::ProposalCreationPolicy)] + ProposalCreationPolicy {}, + /// Lists all of the consumers of proposal hooks for this module. + #[returns(::cw_hooks::HooksResponse)] + ProposalHooks {}, + /// Lists all of the consumers of vote hooks for this module. + #[returns(::cw_hooks::HooksResponse)] + VoteHooks {}, +} + +#[cw_serde] +pub struct VoteMsg { + pub proposal_id: u64, + pub vote: MultipleChoiceVote, +} + +#[cw_serde] +pub enum MigrateMsg { + FromV1 { + /// This field was not present in DAO DAO v1. To migrate, a + /// value must be specified. + /// + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + close_proposal_on_execution_failure: bool, + /// This field was not present in DAO DAO v1. To migrate, a + /// value must be specified. + /// + /// This contains information about how a pre-propose module may be configured. + /// If set to "AnyoneMayPropose", there will be no pre-propose module and consequently, + /// no deposit or membership checks when submitting a proposal. The "ModuleMayPropose" + /// option allows for instantiating a prepropose module which will handle deposit verification and return logic. + pre_propose_info: PreProposeInfo, + }, + FromCompatible {}, +} diff --git a/contracts/proposal/dao-proposal-multiple/src/proposal.rs b/contracts/proposal/dao-proposal-multiple/src/proposal.rs new file mode 100644 index 000000000..4852993db --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/proposal.rs @@ -0,0 +1,848 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, BlockInfo, StdError, StdResult, Uint128}; +use cw_utils::Expiration; +use dao_voting::{ + multiple_choice::{ + CheckedMultipleChoiceOption, MultipleChoiceOptionType, MultipleChoiceVotes, VotingStrategy, + }, + status::Status, + voting::does_vote_count_pass, +}; + +use crate::query::ProposalResponse; + +#[cw_serde] +pub struct MultipleChoiceProposal { + pub title: String, + pub description: String, + /// The address that created this proposal. + pub proposer: Addr, + /// The block height at which this proposal was created. Voting + /// power queries should query for voting power at this block + /// height. + pub start_height: u64, + /// The minimum amount of time this proposal must remain open for + /// voting. The proposal may not pass unless this is expired or + /// None. + pub min_voting_period: Option, + /// The the time at which this proposal will expire and close for + /// additional votes. + pub expiration: Expiration, + /// The options to be chosen from in the vote. + pub choices: Vec, + /// Prosal status (Open, rejected, executed, execution failed, closed, passed) + pub status: Status, + /// Voting settings (threshold, quorum, etc.) + pub voting_strategy: VotingStrategy, + /// The total power when the proposal started (used to calculate percentages) + pub total_power: Uint128, + /// The vote tally. + pub votes: MultipleChoiceVotes, + /// Whether DAO members are allowed to change their votes. + /// When disabled, proposals can be executed as soon as they pass. + /// When enabled, proposals can only be executed after the voting + /// perid has ended and the proposal passed. + pub allow_revoting: bool, +} + +pub enum VoteResult { + SingleWinner(CheckedMultipleChoiceOption), + Tie, +} + +impl MultipleChoiceProposal { + /// Consumes the proposal and returns a version which may be used + /// in a query response. The difference being that proposal + /// statuses are only updated on vote, execute, and close + /// events. It is possible though that since a vote has occured + /// the proposal expiring has changed its status. This method + /// recomputes the status so that queries get accurate + /// information. + pub fn into_response(mut self, block: &BlockInfo, id: u64) -> StdResult { + self.update_status(block)?; + Ok(ProposalResponse { id, proposal: self }) + } + + /// Gets the current status of the proposal. + pub fn current_status(&self, block: &BlockInfo) -> StdResult { + if self.status == Status::Open && self.is_passed(block)? { + Ok(Status::Passed) + } else if self.status == Status::Open + && (self.expiration.is_expired(block) || self.is_rejected(block)?) + { + Ok(Status::Rejected) + } else { + Ok(self.status) + } + } + + /// Sets a proposals status to its current status. + pub fn update_status(&mut self, block: &BlockInfo) -> StdResult<()> { + let new_status = self.current_status(block)?; + self.status = new_status; + Ok(()) + } + + /// Returns true iff this proposal is sure to pass (even before + /// expiration if no future sequence of possible votes can cause + /// it to fail). Passing in the case of multiple choice proposals + /// means that quorum has been met, + /// one of the options that is not "None of the above" + /// has won the most votes, and there is no tie. + pub fn is_passed(&self, block: &BlockInfo) -> StdResult { + // If re-voting is allowed nothing is known until the proposal + // has expired. + if self.allow_revoting && !self.expiration.is_expired(block) { + return Ok(false); + } + // If the min voting period is set and not expired the + // proposal can not yet be passed. This gives DAO members some + // time to remove liquidity / scheme on a recovery plan if a + // single actor accumulates enough tokens to unilaterally pass + // proposals. + if let Some(min) = self.min_voting_period { + if !min.is_expired(block) { + return Ok(false); + } + } + + // Proposal can only pass if quorum has been met. + if does_vote_count_pass( + self.votes.total(), + self.total_power, + self.voting_strategy.get_quorum(), + ) { + let vote_result = self.calculate_vote_result()?; + match vote_result { + // Proposal is not passed if there is a tie. + VoteResult::Tie => return Ok(false), + VoteResult::SingleWinner(winning_choice) => { + // Proposal is not passed if winning choice is None. + if winning_choice.option_type != MultipleChoiceOptionType::None { + // If proposal is expired, quorum has been reached, and winning choice is neither tied nor None, then proposal is passed. + if self.expiration.is_expired(block) { + return Ok(true); + } else { + // If the proposal is not expired but the leading choice cannot + // possibly be outwon by any other choices, the proposal has passed. + return self.is_choice_unbeatable(&winning_choice); + } + } + } + } + } + Ok(false) + } + + pub fn is_rejected(&self, block: &BlockInfo) -> StdResult { + // If re-voting is allowed and the proposal is not expired no + // information is known. + if self.allow_revoting && !self.expiration.is_expired(block) { + return Ok(false); + } + + let vote_result = self.calculate_vote_result()?; + match vote_result { + // Proposal is rejected if there is a tie, and either the proposal is expired or + // there is no voting power left. + VoteResult::Tie => { + let rejected = + self.expiration.is_expired(block) || self.total_power == self.votes.total(); + Ok(rejected) + } + VoteResult::SingleWinner(winning_choice) => { + match ( + does_vote_count_pass( + self.votes.total(), + self.total_power, + self.voting_strategy.get_quorum(), + ), + self.expiration.is_expired(block), + ) { + // Quorum is met and proposal is expired. + (true, true) => { + // Proposal is rejected if "None" is the winning option. + if winning_choice.option_type == MultipleChoiceOptionType::None { + return Ok(true); + } + Ok(false) + } + // Proposal is not expired, quorum is either is met or unmet. + (true, false) | (false, false) => { + // If the proposal is not expired and the leading choice is None and it cannot + // possibly be outwon by any other choices, the proposal is rejected. + if winning_choice.option_type == MultipleChoiceOptionType::None { + return self.is_choice_unbeatable(&winning_choice); + } + Ok(false) + } + // Quorum is not met and proposal is expired. + (false, true) => Ok(true), + } + } + } + } + + /// Find the option with the highest vote weight, and note if there is a tie. + pub fn calculate_vote_result(&self) -> StdResult { + match self.voting_strategy { + VotingStrategy::SingleChoice { quorum: _ } => { + // We expect to have at least 3 vote weights + if let Some(max_weight) = self.votes.vote_weights.iter().max_by(|&a, &b| a.cmp(b)) { + let top_choices: Vec<(usize, &Uint128)> = self + .votes + .vote_weights + .iter() + .enumerate() + .filter(|x| x.1 == max_weight) + .collect(); + + // If more than one choice has the highest number of votes, we have a tie. + if top_choices.len() > 1 { + return Ok(VoteResult::Tie); + } + + match top_choices.first() { + Some(winning_choice) => { + return Ok(VoteResult::SingleWinner( + self.choices[winning_choice.0].clone(), + )); + } + None => { + return Err(StdError::generic_err("no votes found")); + } + } + } + Err(StdError::not_found("max vote weight")) + } + } + } + + /// Ensure that with the remaining vote power, the choice with the second highest votes + /// cannot overtake the first choice. + fn is_choice_unbeatable( + &self, + winning_choice: &CheckedMultipleChoiceOption, + ) -> StdResult { + let winning_choice_power = self.votes.vote_weights[winning_choice.index as usize]; + if let Some(second_choice_power) = self + .votes + .vote_weights + .iter() + .filter(|&x| x < &winning_choice_power) + .max_by(|&a, &b| a.cmp(b)) + { + // Check if the remaining vote power can be used to overtake the current winning choice. + let remaining_vote_power = self.total_power - self.votes.total(); + match winning_choice.option_type { + MultipleChoiceOptionType::Standard => { + if winning_choice_power > *second_choice_power + remaining_vote_power { + return Ok(true); + } + } + MultipleChoiceOptionType::None => { + // If the winning choice is None, and we can at most achieve a tie, + // this choice is unbeatable because a tie will also fail the proposal. This is why we check for '>=' in this case + // rather than '>'. + if winning_choice_power >= *second_choice_power + remaining_vote_power { + return Ok(true); + } + } + } + } else { + return Err(StdError::not_found("second highest vote weight")); + } + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use cosmwasm_std::testing::mock_env; + use dao_voting::multiple_choice::{MultipleChoiceOption, MultipleChoiceOptions}; + + fn create_proposal( + block: &BlockInfo, + voting_strategy: VotingStrategy, + votes: MultipleChoiceVotes, + total_power: Uint128, + is_expired: bool, + allow_revoting: bool, + ) -> MultipleChoiceProposal { + // The last option that gets added in into_checked is always the none of the above option + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let expiration: Expiration = if is_expired { + Expiration::AtHeight(block.height - 5) + } else { + Expiration::AtHeight(block.height + 5) + }; + + let mc_options = MultipleChoiceOptions { options }; + MultipleChoiceProposal { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + proposer: Addr::unchecked("CREATOR"), + start_height: mock_env().block.height, + expiration, + // The last option that gets added in into_checked is always the none of the above option + choices: mc_options.into_checked().unwrap().options, + status: Status::Open, + voting_strategy, + total_power, + votes, + allow_revoting, + min_voting_period: None, + } + } + + #[test] + fn test_majority_quorum() { + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Majority {}, + }; + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(1), Uint128::new(0), Uint128::new(0)], + }; + + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(1), + false, + false, + ); + + // Quorum was met and all votes were cast, should be passed. + assert!(prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(0), Uint128::new(0), Uint128::new(1)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(1), + false, + false, + ); + + // Quorum was met but none of the above won, should be rejected. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(1), Uint128::new(0), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(100), + false, + false, + ); + + // Quorum was not met and is not expired, should be open. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(1), Uint128::new(0), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(100), + true, + false, + ); + + // Quorum was not met and it is expired, should be rejected. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(50), Uint128::new(50), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(100), + true, + false, + ); + + // Quorum was met but it is a tie and expired, should be rejected. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(50), Uint128::new(50), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(150), + false, + false, + ); + + // Quorum was met but it is a tie but not expired and still voting power remains, should be open. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + } + + #[test] + fn test_percentage_quorum() { + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Percent( + cosmwasm_std::Decimal::percent(10), + ), + }; + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(1), Uint128::new(0), Uint128::new(0)], + }; + + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(1), + false, + false, + ); + + // Quorum was met and all votes were cast, should be passed. + assert!(prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(0), Uint128::new(0), Uint128::new(1)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(1), + false, + false, + ); + + // Quorum was met but none of the above won, should be rejected. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(1), Uint128::new(0), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(100), + false, + false, + ); + + // Quorum was not met and is not expired, should be open. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(1), Uint128::new(0), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(101), + true, + false, + ); + + // Quorum was not met and it is expired, should be rejected. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(50), Uint128::new(50), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(10000), + true, + false, + ); + + // Quorum was met but it is a tie and expired, should be rejected. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(50), Uint128::new(50), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(150), + false, + false, + ); + + // Quorum was met but it is a tie but not expired and still voting power remains, should be open. + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + } + + #[test] + fn test_unbeatable_none_option() { + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Percent( + cosmwasm_std::Decimal::percent(10), + ), + }; + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(0), Uint128::new(50), Uint128::new(500)], + }; + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(1000), + false, + false, + ); + + // Quorum was met but none of the above is winning, but it also can't be beat (only a tie at best), should be rejected + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + } + + #[test] + fn test_quorum_rounding() { + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Percent( + cosmwasm_std::Decimal::percent(10), + ), + }; + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(10), Uint128::new(0), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(100), + true, + false, + ); + + // Quorum was met and proposal expired, should pass + assert!(prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + + // High Precision rounding + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Percent( + cosmwasm_std::Decimal::percent(100), + ), + }; + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(999999), Uint128::new(0), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(1000000), + true, + false, + ); + + // Quorum was not met and expired, should reject + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + + // High Precision rounding + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Percent( + cosmwasm_std::Decimal::percent(99), + ), + }; + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(9888889), Uint128::new(0), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(10000000), + true, + false, + ); + + // Quorum was not met and expired, should reject + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + } + + #[test] + fn test_tricky_pass() { + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Percent( + cosmwasm_std::Decimal::from_ratio(7u32, 13u32), + ), + }; + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(7), Uint128::new(0), Uint128::new(6)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes.clone(), + Uint128::new(13), + true, + false, + ); + + // Should pass if expired + assert!(prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(13), + false, + false, + ); + + // Should pass if not expired + assert!(prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + } + + #[test] + fn test_tricky_pass_majority() { + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Majority {}, + }; + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(7), Uint128::new(0), Uint128::new(0)], + }; + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes.clone(), + Uint128::new(13), + true, + false, + ); + + // Should pass if majority voted + assert!(prop.is_passed(&env.block).unwrap()); + assert!(!prop.is_rejected(&env.block).unwrap()); + + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(14), + true, + false, + ); + + // Shouldn't pass if only half voted + assert!(!prop.is_passed(&env.block).unwrap()); + assert!(prop.is_rejected(&env.block).unwrap()); + } + + #[test] + fn test_majority_revote_pass() { + // Revoting being allowed means that proposals may not be + // passed or rejected before they expire. + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Majority {}, + }; + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(6), Uint128::new(0), Uint128::new(0)], + }; + + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes.clone(), + Uint128::new(10), + false, + true, + ); + // Quorum reached, but proposal is still active => no pass + assert!(!prop.is_passed(&env.block).unwrap()); + + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(10), + true, + true, + ); + // Quorum reached & proposal has expired => pass + assert!(prop.is_passed(&env.block).unwrap()); + } + + #[test] + fn test_majority_revote_rejection() { + // Revoting being allowed means that proposals may not be + // passed or rejected before they expire. + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Majority {}, + }; + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(5), Uint128::new(5), Uint128::new(0)], + }; + + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes.clone(), + Uint128::new(10), + false, + true, + ); + // Everyone voted and proposal is in a tie... + assert_eq!(prop.total_power, prop.votes.total()); + assert_eq!(prop.votes.vote_weights[0], prop.votes.vote_weights[1]); + // ... but proposal is still active => no rejection + assert!(!prop.is_rejected(&env.block).unwrap()); + + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(10), + true, + true, + ); + // Proposal has expired and ended in a tie => rejection + assert_eq!(prop.votes.vote_weights[0], prop.votes.vote_weights[1]); + assert!(prop.is_rejected(&env.block).unwrap()); + } + + #[test] + fn test_percentage_revote_pass() { + // Revoting being allowed means that proposals may not be + // passed or rejected before they expire. + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Percent( + cosmwasm_std::Decimal::percent(80), + ), + }; + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(81), Uint128::new(0), Uint128::new(0)], + }; + + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes.clone(), + Uint128::new(100), + false, + true, + ); + // Quorum reached, but proposal is still active => no pass + assert!(!prop.is_passed(&env.block).unwrap()); + + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(100), + true, + true, + ); + // Quorum reached & proposal has expired => pass + assert!(prop.is_passed(&env.block).unwrap()); + } + + #[test] + fn test_percentage_revote_rejection() { + // Revoting being allowed means that proposals may not be + // passed or rejected before they expire. + let env = mock_env(); + let voting_strategy = VotingStrategy::SingleChoice { + quorum: dao_voting::threshold::PercentageThreshold::Percent( + cosmwasm_std::Decimal::percent(80), + ), + }; + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(90), Uint128::new(0), Uint128::new(0)], + }; + + let prop = create_proposal( + &env.block, + voting_strategy.clone(), + votes, + Uint128::new(100), + false, + true, + ); + // Quorum reached, but proposal is still active => no rejection + assert!(!prop.is_rejected(&env.block).unwrap()); + + let votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(50), Uint128::new(0), Uint128::new(0)], + }; + + let prop = create_proposal( + &env.block, + voting_strategy, + votes, + Uint128::new(100), + true, + true, + ); + // No quorum reached & proposal has expired => rejection + assert!(prop.is_rejected(&env.block).unwrap()); + } +} diff --git a/contracts/proposal/dao-proposal-multiple/src/query.rs b/contracts/proposal/dao-proposal-multiple/src/query.rs new file mode 100644 index 000000000..35862161f --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/query.rs @@ -0,0 +1,50 @@ +use crate::{proposal::MultipleChoiceProposal, state::Config}; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; + +use dao_voting::multiple_choice::MultipleChoiceVote; + +#[cw_serde] +pub struct ProposalListResponse { + pub proposals: Vec, +} + +/// Information about a proposal returned by proposal queries. +#[cw_serde] +pub struct ProposalResponse { + pub id: u64, + pub proposal: MultipleChoiceProposal, +} + +/// Information about a vote that was cast. +#[cw_serde] +pub struct VoteInfo { + /// The address that voted. + pub voter: Addr, + /// Position on the vote. + pub vote: MultipleChoiceVote, + /// The voting power behind the vote. + pub power: Uint128, + /// The rationale behind the vote. + pub rationale: Option, +} + +#[cw_serde] +pub struct VoteResponse { + pub vote: Option, +} + +#[cw_serde] +pub struct VoteListResponse { + pub votes: Vec, +} + +#[cw_serde] +pub struct VoterResponse { + pub weight: Option, +} + +#[cw_serde] +pub struct ConfigResponse { + pub config: Config, +} diff --git a/contracts/proposal/dao-proposal-multiple/src/state.rs b/contracts/proposal/dao-proposal-multiple/src/state.rs new file mode 100644 index 000000000..f9c6baa59 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/state.rs @@ -0,0 +1,70 @@ +use crate::proposal::MultipleChoiceProposal; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_hooks::Hooks; +use cw_storage_plus::{Item, Map}; +use cw_utils::Duration; +use dao_voting::{ + multiple_choice::{MultipleChoiceVote, VotingStrategy}, + pre_propose::ProposalCreationPolicy, +}; + +/// The proposal module's configuration. +#[cw_serde] +pub struct Config { + /// The threshold a proposal must reach to complete. + pub voting_strategy: VotingStrategy, + /// The minimum amount of time a proposal must be open before + /// passing. A proposal may fail before this amount of time has + /// elapsed, but it will not pass. This can be useful for + /// preventing governance attacks wherein an attacker aquires a + /// large number of tokens and forces a proposal through. + pub min_voting_period: Option, + /// The default maximum amount of time a proposal may be voted on + /// before expiring. + pub max_voting_period: Duration, + /// If set to true only members may execute passed + /// proposals. Otherwise, any address may execute a passed + /// proposal. + pub only_members_execute: bool, + /// Allows changing votes before the proposal expires. If this is + /// enabled proposals will not be able to complete early as final + /// vote information is not known until the time of proposal + /// expiration. + pub allow_revoting: bool, + /// The address of the DAO that this governance module is + /// associated with. + pub dao: Addr, + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + pub close_proposal_on_execution_failure: bool, +} + +// Each ballot stores a chosen vote and corresponding voting power and rationale. +#[cw_serde] +pub struct Ballot { + /// The amount of voting power behind the vote. + pub power: Uint128, + /// The position. + pub vote: MultipleChoiceVote, + /// An optional rationale for why this vote was cast. + pub rationale: Option, +} + +/// The current top level config for the module. +pub const CONFIG: Item = Item::new("config"); +pub const PROPOSAL_COUNT: Item = Item::new("proposal_count"); +pub const PROPOSALS: Map = Map::new("proposals"); +pub const BALLOTS: Map<(u64, &Addr), Ballot> = Map::new("ballots"); +/// Consumers of proposal state change hooks. +pub const PROPOSAL_HOOKS: Hooks = Hooks::new("proposal_hooks"); +/// Consumers of vote hooks. +pub const VOTE_HOOKS: Hooks = Hooks::new("vote_hooks"); +/// The address of the pre-propose module associated with this +/// proposal module (if any). +pub const CREATION_POLICY: Item = Item::new("creation_policy"); diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs b/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs new file mode 100644 index 000000000..fe09e69d1 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs @@ -0,0 +1,398 @@ +use crate::msg::{ExecuteMsg, InstantiateMsg}; +use crate::testing::execute::{make_proposal, mint_cw20s}; +use crate::testing::instantiate::{ + _get_default_token_dao_proposal_module_instantiate, + instantiate_with_multiple_staked_balances_governance, +}; +use crate::testing::queries::{ + query_balance_cw20, query_dao_token, query_multiple_proposal_module, query_proposal, +}; +use crate::testing::tests::{get_pre_propose_info, ALTERNATIVE_ADDR, CREATOR_ADDR}; +use crate::ContractError; +use cosmwasm_std::{to_binary, Addr, CosmosMsg, Decimal, Uint128, WasmMsg}; +use cw20::Cw20Coin; +use cw_multi_test::{next_block, App, Executor}; +use cw_utils::Duration; +use dao_voting::{ + deposit::{DepositRefundPolicy, UncheckedDepositInfo}, + multiple_choice::{ + MultipleChoiceOption, MultipleChoiceOptions, MultipleChoiceVote, VotingStrategy, + }, + status::Status, + threshold::PercentageThreshold, +}; + +struct CommonTest { + app: App, + proposal_module: Addr, + proposal_id: u64, +} +fn setup_test(_messages: Vec) -> CommonTest { + let mut app = App::default(); + let instantiate = _get_default_token_dao_proposal_module_instantiate(&mut app); + let core_addr = + instantiate_with_multiple_staked_balances_governance(&mut app, instantiate, None); + let proposal_module = query_multiple_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + // Mint some tokens to pay the proposal deposit. + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + + let options = vec![ + MultipleChoiceOption { + title: "title 1".to_string(), + description: "multiple choice option 1".to_string(), + msgs: vec![], + }, + MultipleChoiceOption { + title: "title 2".to_string(), + description: "multiple choice option 2".to_string(), + msgs: vec![], + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, mc_options); + + CommonTest { + app, + proposal_module, + proposal_id, + } +} + +// A proposal that is still accepting votes (is open) cannot +// be executed. Any attempts to do so should fail and return +// an error. +#[test] +fn test_execute_proposal_open() { + let CommonTest { + mut app, + proposal_module, + proposal_id, + } = setup_test(vec![]); + + app.update_block(next_block); + + // assert proposal is open + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Open); + + // attempt to execute and assert that it fails + let err = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module, + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::NotPassed {})) +} + +// A proposal can be executed if and only if it passed. +// Any attempts to execute a proposal that has been rejected +// or closed (after rejection) should fail and return an error. +#[test] +fn test_execute_proposal_rejected_closed() { + let CommonTest { + mut app, + proposal_module, + proposal_id, + } = setup_test(vec![]); + + app.update_block(next_block); + + // assert proposal is open + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Open); + + // Vote on both options to reject the proposal + let vote = MultipleChoiceVote { option_id: 0 }; + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + .unwrap(); + + let vote = MultipleChoiceVote { option_id: 1 }; + app.execute_contract( + Addr::unchecked(ALTERNATIVE_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Rejected); + + // attempt to execute and assert that it fails + let err = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::NotPassed {})); + + app.update_block(next_block); + + // close the proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap(); + + // assert prop is closed and attempt to execute it + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Closed); + + let err = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module, + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::NotPassed {})); +} + +// A proposal can only be executed once. Any subsequent +// attempts to execute it should fail and return an error. +#[test] +fn test_execute_proposal_more_than_once() { + let CommonTest { + mut app, + proposal_module, + proposal_id, + } = setup_test(vec![]); + + app.update_block(next_block); + + // get the proposal to pass + let vote = MultipleChoiceVote { option_id: 0 }; + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + .unwrap(); + app.execute_contract( + Addr::unchecked(ALTERNATIVE_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Passed); + + // execute the proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Executed); + + let err = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module, + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::NotPassed {})); +} + +// Users should be able to submit votes past the proposal +// expiration date. Such votes do not affect the outcome +// of the proposals; instead, they are meant to allow +// voters to voice their opinion. +#[test] +pub fn test_allow_voting_after_proposal_execution_pre_expiration_cw20() { + let mut app = App::default(); + + let instantiate = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(66)), + }, + max_voting_period: Duration::Time(604800), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(10_000_000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + close_proposal_on_execution_failure: true, + }; + + let core_addr = instantiate_with_multiple_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: ALTERNATIVE_ADDR.to_string(), + amount: Uint128::new(50_000_000), + }, + ]), + ); + let proposal_module = query_multiple_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + // Mint some tokens to pay the proposal deposit. + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + + // Option 0 would mint 100_000_000 tokens for CREATOR_ADDR + let msg = cw20::Cw20ExecuteMsg::Mint { + recipient: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }; + let binary_msg = to_binary(&msg).unwrap(); + + let options = vec![ + MultipleChoiceOption { + title: "title 1".to_string(), + description: "multiple choice option 1".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: gov_token.to_string(), + msg: binary_msg, + funds: vec![], + } + .into()], + }, + MultipleChoiceOption { + title: "title 2".to_string(), + description: "multiple choice option 2".to_string(), + msgs: vec![], + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, mc_options); + + // assert initial CREATOR_ADDR address balance is 0 + let balance = query_balance_cw20(&app, gov_token.to_string(), CREATOR_ADDR); + assert_eq!(balance, Uint128::zero()); + + app.update_block(next_block); + + let vote = MultipleChoiceVote { option_id: 0 }; + + // someone votes enough to pass the proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + // assert proposal is passed with expected votes + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Passed); + assert_eq!(prop.proposal.votes.vote_weights[0], Uint128::new(100000000)); + assert_eq!(prop.proposal.votes.vote_weights[1], Uint128::new(0)); + + // someone wakes up and casts their vote to express their + // opinion (not affecting the result of proposal) + let vote = MultipleChoiceVote { option_id: 1 }; + app.execute_contract( + Addr::unchecked(ALTERNATIVE_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + // assert proposal is passed with expected votes + let prop = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(prop.proposal.status, Status::Passed); + assert_eq!(prop.proposal.votes.vote_weights[0], Uint128::new(100000000)); + assert_eq!(prop.proposal.votes.vote_weights[1], Uint128::new(50000000)); + + // execute the proposal expecting + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // assert option 0 message executed as expected changed as expected + let balance = query_balance_cw20(&app, gov_token.to_string(), CREATOR_ADDR); + assert_eq!(balance, Uint128::new(110_000_000)); +} diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs new file mode 100644 index 000000000..32eb43168 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs @@ -0,0 +1,842 @@ +use cosmwasm_std::{coins, Addr, Decimal, Uint128}; +use cw20::Cw20Coin; +use cw_denom::CheckedDenom; +use cw_multi_test::{App, BankSudo, Executor}; +use dao_interface::state::ProposalModule; +use dao_testing::ShouldExecute; +use dao_voting::{ + deposit::{CheckedDepositInfo, UncheckedDepositInfo}, + multiple_choice::{ + MultipleChoiceOption, MultipleChoiceOptions, MultipleChoiceVote, VotingStrategy, + }, + status::Status, + threshold::PercentageThreshold, +}; +use rand::{prelude::SliceRandom, Rng}; +use std::panic; + +use crate::{ + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + query::{ProposalResponse, VoteInfo, VoteResponse}, + testing::{ + instantiate::{ + instantiate_with_cw20_balances_governance, instantiate_with_staked_balances_governance, + }, + queries::query_deposit_config_and_pre_propose_module, + tests::{get_pre_propose_info, proposal_multiple_contract, TestMultipleChoiceVote}, + }, +}; +use dao_pre_propose_multiple as cppm; + +fn do_votes_cw20_balances( + votes: Vec, + voting_strategy: VotingStrategy, + expected_status: Status, + total_supply: Option, + should_expire: bool, +) { + do_test_votes( + votes, + voting_strategy, + expected_status, + total_supply, + None::, + should_expire, + instantiate_with_staked_balances_governance, + ); +} + +fn do_votes_staked_balances( + votes: Vec, + voting_strategy: VotingStrategy, + expected_status: Status, + total_supply: Option, + should_expire: bool, +) { + do_test_votes( + votes, + voting_strategy, + expected_status, + total_supply, + None::, + should_expire, + instantiate_with_staked_balances_governance, + ); +} + +fn do_votes_cw4_weights( + votes: Vec, + voting_strategy: VotingStrategy, + expected_status: Status, + total_supply: Option, + should_expire: bool, +) { + do_test_votes( + votes, + voting_strategy, + expected_status, + total_supply, + None::, + should_expire, + instantiate_with_staked_balances_governance, + ); +} + +// Creates multiple choice proposal with provided config and executes provided votes against it. +fn do_test_votes( + votes: Vec, + voting_strategy: VotingStrategy, + expected_status: Status, + total_supply: Option, + deposit_info: Option, + should_expire: bool, + setup_governance: F, +) -> (App, Addr) +where + F: Fn(&mut App, InstantiateMsg, Option>) -> Addr, +{ + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let mut initial_balances = votes + .iter() + .map(|TestMultipleChoiceVote { voter, weight, .. }| Cw20Coin { + address: voter.to_string(), + amount: *weight, + }) + .collect::>(); + let initial_balances_supply = votes.iter().fold(Uint128::zero(), |p, n| p + n.weight); + let to_fill = total_supply.map(|total_supply| total_supply - initial_balances_supply); + if let Some(fill) = to_fill { + initial_balances.push(Cw20Coin { + address: "filler".to_string(), + amount: fill, + }) + } + + let pre_propose_info = get_pre_propose_info(&mut app, deposit_info, false); + + let proposer = match votes.first() { + Some(vote) => vote.voter.clone(), + None => panic!("do_test_votes must have at least one vote."), + }; + + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + close_proposal_on_execution_failure: true, + pre_propose_info, + }; + + let governance_addr = setup_governance(&mut app, instantiate, Some(initial_balances)); + + let governance_modules: Vec = app + .wrap() + .query_wasm_smart( + governance_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + // Allow a proposal deposit as needed. + let (deposit_config, pre_propose_module) = + query_deposit_config_and_pre_propose_module(&app, &govmod); + + // Increase allowance to pay the cw20 deposit if needed. + if let Some(CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + amount, + .. + }) = deposit_config.deposit_info + { + app.execute_contract( + Addr::unchecked(&proposer), + token.clone(), + &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + spender: pre_propose_module.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); + } + + let funds = if let Some(CheckedDepositInfo { + denom: CheckedDenom::Native(ref denom), + amount, + .. + }) = deposit_config.deposit_info + { + // Mint the needed tokens to create the deposit. + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: proposer.clone(), + amount: coins(amount.u128(), denom), + })) + .unwrap(); + coins(amount.u128(), denom) + } else { + vec![] + }; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + app.execute_contract( + Addr::unchecked(&proposer), + pre_propose_module, + &cppm::ExecuteMsg::Propose { + msg: cppm::ProposeMessage::Propose { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + choices: mc_options, + }, + }, + &funds, + ) + .unwrap(); + + // Cast votes. + for vote in votes { + let TestMultipleChoiceVote { + voter, + position, + weight, + should_execute, + } = vote; + // Vote on the proposal. + let res = app.execute_contract( + Addr::unchecked(voter.clone()), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: position, + rationale: None, + }, + &[], + ); + match should_execute { + ShouldExecute::Yes => { + if res.is_err() { + println!("{:?}", res.err()); + panic!() + } + // Check that the vote was recorded correctly. + let vote: VoteResponse = app + .wrap() + .query_wasm_smart( + govmod.clone(), + &QueryMsg::GetVote { + proposal_id: 1, + voter: voter.clone(), + }, + ) + .unwrap(); + let expected = VoteResponse { + vote: Some(VoteInfo { + voter: Addr::unchecked(&voter), + vote: position, + power: match deposit_config.deposit_info { + Some(CheckedDepositInfo { + amount, + denom: CheckedDenom::Cw20(_), + .. + }) => { + if proposer == voter { + weight - amount + } else { + weight + } + } + // Native token deposits shouldn't impact + // expected voting power. + _ => weight, + }, + rationale: None, + }), + }; + assert_eq!(vote, expected) + } + ShouldExecute::No => { + res.unwrap_err(); + } + ShouldExecute::Meh => (), + } + } + + // Expire the proposal if this is expected. + if should_expire { + app.update_block(|block| block.height += 100); + } + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart(govmod, &QueryMsg::Proposal { proposal_id: 1 }) + .unwrap(); + + assert_eq!(proposal.proposal.status, expected_status); + + (app, governance_addr) +} + +// Creates a proposal and then executes a series of votes on those +// proposals. Asserts both that those votes execute as expected and +// that the final status of the proposal is what is expected. Returns +// the address of the governance contract that it has created so that +// callers may do additional inspection of the contract's state. +pub fn do_test_votes_cw20_balances( + votes: Vec, + voting_strategy: VotingStrategy, + expected_status: Status, + total_supply: Option, + deposit_info: Option, + should_expire: bool, +) -> (App, Addr) { + do_test_votes( + votes, + voting_strategy, + expected_status, + total_supply, + deposit_info, + should_expire, + instantiate_with_cw20_balances_governance, + ) +} + +pub fn test_simple_votes(do_test_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + // Vote for one option, passes + do_test_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Passed, + None, + false, + ); + + // Vote for none of the above, gets rejected + do_test_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Rejected, + None, + false, + ) +} + +pub fn test_vote_invalid_option(do_test_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + // Vote for out of bounds option + do_test_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 10 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::No, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Open, + None, + false, + ); +} + +pub fn test_vote_no_overflow(do_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + do_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(u128::max_value()), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Passed, + None, + false, + ); + + do_votes( + vec![ + TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }, + TestMultipleChoiceVote { + voter: "bob".to_string(), + position: MultipleChoiceVote { option_id: 1 }, + weight: Uint128::new(u128::max_value() - 1), + should_execute: ShouldExecute::Yes, + }, + ], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Passed, + None, + false, + ); +} + +pub fn test_vote_tied_rejected(do_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + do_votes( + vec![ + TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }, + TestMultipleChoiceVote { + voter: "bob".to_string(), + position: MultipleChoiceVote { option_id: 1 }, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }, + ], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Rejected, + None, + false, + ); +} + +pub fn test_vote_none_of_the_above_only(do_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + do_votes( + 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()), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Rejected, + None, + false, + ); + + for i in 0..101 { + do_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(u64::max_value().into()), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(i)), + }, + Status::Rejected, + None, + false, + ); + } +} + +pub fn test_tricky_rounding(do_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + // This tests the smallest possible round up for passing + // thresholds we can have. Specifically, a 1% passing threshold + // and 1 total vote. This should round up and only pass if there + // are 1 or more yes votes. + do_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(1)), + }, + Status::Passed, + Some(Uint128::new(100)), + true, + ); + + do_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(1)), + }, + Status::Passed, + Some(Uint128::new(1000)), + true, + ); + + // High Precision + // Proposal should be rejected if < 1% have voted and proposal expires + do_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 1 }, + weight: Uint128::new(9999999), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(1)), + }, + Status::Rejected, + Some(Uint128::new(1000000000)), + true, + ); + + // Proposal should be rejected if quorum is met but "none of the above" is the winning option. + do_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(1)), + }, + Status::Rejected, + None, + false, + ); +} + +pub fn test_no_double_votes(do_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + do_votes( + vec![ + TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 1 }, + weight: Uint128::new(2), + should_execute: ShouldExecute::Yes, + }, + TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 1 }, + weight: Uint128::new(2), + should_execute: ShouldExecute::No, + }, + ], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + // NOTE: Updating our cw20-base version will cause this to + // fail. In versions of cw20-base before Feb 15 2022 (the one + // we use at the time of writing) it was allowed to have an + // initial balance that repeats for a given address but it + // would cause miscalculation of the total supply. In this + // case the total supply is miscomputed to be 4 so this is + // assumed to have 2 abstain votes out of 4 possible votes. + Status::Open, + Some(Uint128::new(10)), + false, + ) +} + +pub fn test_majority_vs_half(do_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + // Half + do_votes( + vec![ + TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + ], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(50)), + }, + Status::Passed, + Some(Uint128::new(40)), + true, + ); + + // Majority + do_votes( + vec![ + TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + ], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Rejected, + Some(Uint128::new(40)), + true, + ); +} + +pub fn test_pass_exactly_quorum(do_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + do_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(60), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(60)), + }, + Status::Passed, + Some(Uint128::new(100)), + false, + ); + + // None of the above wins + do_votes( + vec![TestMultipleChoiceVote { + voter: "bluenote".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(60), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(60)), + }, + Status::Rejected, + Some(Uint128::new(100)), + false, + ); +} + +pub fn fuzz_voting(do_votes: F) +where + F: Fn(Vec, VotingStrategy, Status, Option, bool), +{ + let mut rng = rand::thread_rng(); + let dist = rand::distributions::Uniform::::new(1, 200); + for _ in 0..10 { + let zero: Vec = (0..50).map(|_| rng.sample(dist)).collect(); + let one: Vec = (0..50).map(|_| rng.sample(dist)).collect(); + let none: Vec = (0..50).map(|_| rng.sample(dist)).collect(); + + let zero_sum: u64 = zero.iter().sum(); + let one_sum: u64 = one.iter().sum(); + let none_sum: u64 = none.iter().sum(); + + let mut sums = vec![zero_sum, one_sum, none_sum]; + sums.sort_unstable(); + + // If none of the above wins or there is a tie between second and first choice. + let expected_status: Status = if *sums.last().unwrap() == none_sum || sums[1] == sums[2] { + Status::Rejected + } else { + Status::Passed + }; + + let zero = zero + .into_iter() + .enumerate() + .map(|(idx, weight)| TestMultipleChoiceVote { + voter: format!("zero_{idx}"), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(weight as u128), + should_execute: ShouldExecute::Meh, + }); + let one = one + .into_iter() + .enumerate() + .map(|(idx, weight)| TestMultipleChoiceVote { + voter: format!("one_{idx}"), + position: MultipleChoiceVote { option_id: 1 }, + weight: Uint128::new(weight as u128), + should_execute: ShouldExecute::Meh, + }); + + let none = none + .into_iter() + .enumerate() + .map(|(idx, weight)| TestMultipleChoiceVote { + voter: format!("none_{idx}"), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(weight as u128), + should_execute: ShouldExecute::Meh, + }); + + let mut votes = zero.chain(one).chain(none).collect::>(); + votes.shuffle(&mut rng); + + do_votes( + votes, + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + expected_status, + None, + true, + ); + } +} + +#[test] +fn test_vote_simple() { + test_simple_votes(do_votes_cw20_balances); + test_simple_votes(do_votes_cw4_weights); + test_simple_votes(do_votes_staked_balances) +} + +#[test] +fn test_vote_out_of_bounds() { + test_vote_invalid_option(do_votes_cw20_balances); + test_vote_invalid_option(do_votes_cw4_weights); + test_vote_invalid_option(do_votes_staked_balances); +} + +#[test] +fn test_no_overflow() { + test_vote_no_overflow(do_votes_cw20_balances); + test_vote_no_overflow(do_votes_staked_balances); + test_vote_no_overflow(do_votes_cw4_weights) +} + +#[test] +fn test_quorum_not_met() { + test_vote_no_overflow(do_votes_cw20_balances); + test_vote_no_overflow(do_votes_staked_balances); + test_vote_no_overflow(do_votes_cw4_weights) +} + +#[test] +fn test_votes_tied() { + test_vote_tied_rejected(do_votes_cw20_balances); + test_vote_tied_rejected(do_votes_staked_balances); + test_vote_tied_rejected(do_votes_cw4_weights) +} + +#[test] +fn test_votes_none_of_the_above() { + test_vote_none_of_the_above_only(do_votes_cw20_balances); + test_vote_none_of_the_above_only(do_votes_staked_balances); + test_vote_none_of_the_above_only(do_votes_cw4_weights) +} + +#[test] +fn test_rounding() { + test_tricky_rounding(do_votes_cw20_balances); + test_tricky_rounding(do_votes_staked_balances); + test_tricky_rounding(do_votes_cw4_weights) +} + +#[test] +fn test_no_double_vote() { + test_no_double_votes(do_votes_cw20_balances); + test_no_double_votes(do_votes_staked_balances); + test_no_double_votes(do_votes_cw4_weights) +} + +#[test] +fn test_majority_half() { + test_majority_vs_half(do_votes_cw20_balances); + test_majority_vs_half(do_votes_staked_balances); + test_majority_vs_half(do_votes_cw4_weights) +} + +#[test] +fn test_pass_exact_quorum() { + test_pass_exactly_quorum(do_votes_cw20_balances); + test_pass_exactly_quorum(do_votes_staked_balances); + test_pass_exactly_quorum(do_votes_cw4_weights) +} + +#[test] +fn fuzz_votes_cw20_balances() { + fuzz_voting(do_votes_cw20_balances) +} + +#[test] +fn fuzz_votes_cw4_weights() { + fuzz_voting(do_votes_cw4_weights) +} + +#[test] +fn fuzz_votes_staked_balances() { + fuzz_voting(do_votes_staked_balances) +} diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/execute.rs b/contracts/proposal/dao-proposal-multiple/src/testing/execute.rs new file mode 100644 index 000000000..bc5168e9f --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/testing/execute.rs @@ -0,0 +1,132 @@ +use cosmwasm_std::{coins, Addr, Uint128}; +use cw_multi_test::{App, Executor}; + +use cw_denom::CheckedDenom; +use dao_pre_propose_multiple as cppm; +use dao_voting::{ + deposit::CheckedDepositInfo, multiple_choice::MultipleChoiceOptions, + pre_propose::ProposalCreationPolicy, +}; + +use crate::{ + msg::{ExecuteMsg, QueryMsg}, + query::ProposalResponse, + testing::queries::{query_creation_policy, query_pre_proposal_multiple_config}, +}; + +// Creates a proposal then checks that the proposal was created with +// the specified messages and returns the ID of the proposal. +// +// This expects that the proposer already has the needed tokens to pay +// the deposit. +pub fn make_proposal( + app: &mut App, + proposal_multiple: &Addr, + proposer: &str, + choices: MultipleChoiceOptions, +) -> u64 { + let proposal_creation_policy = query_creation_policy(app, proposal_multiple); + + // Collect the funding. + let funds = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => vec![], + ProposalCreationPolicy::Module { + addr: ref pre_propose, + } => { + let deposit_config = query_pre_proposal_multiple_config(app, pre_propose); + match deposit_config.deposit_info { + Some(CheckedDepositInfo { + denom, + amount, + refund_policy: _, + }) => match denom { + CheckedDenom::Native(denom) => coins(amount.u128(), denom), + CheckedDenom::Cw20(addr) => { + // Give an allowance, no funds. + app.execute_contract( + Addr::unchecked(proposer), + addr, + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: pre_propose.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); + vec![] + } + }, + None => vec![], + } + } + }; + + // Make the proposal. + match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => app + .execute_contract( + Addr::unchecked(proposer), + proposal_multiple.clone(), + &ExecuteMsg::Propose { + title: "title".to_string(), + description: "description".to_string(), + choices, + proposer: None, + }, + &[], + ) + .unwrap(), + ProposalCreationPolicy::Module { addr } => app + .execute_contract( + Addr::unchecked(proposer), + addr, + &cppm::ExecuteMsg::Propose { + msg: cppm::ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + choices, + }, + }, + &funds, + ) + .unwrap(), + }; + + let id: u64 = app + .wrap() + .query_wasm_smart(proposal_multiple, &QueryMsg::NextProposalId {}) + .unwrap(); + let id = id - 1; + + // Check that the proposal was created as expected. + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart(proposal_multiple, &QueryMsg::Proposal { proposal_id: id }) + .unwrap(); + + assert_eq!(proposal.proposal.proposer, Addr::unchecked(proposer)); + assert_eq!(proposal.proposal.title, "title".to_string()); + assert_eq!(proposal.proposal.description, "description".to_string()); + + id +} + +pub(crate) fn mint_cw20s( + app: &mut App, + cw20_contract: &Addr, + sender: &Addr, + receiver: &str, + amount: u128, +) { + app.execute_contract( + sender.clone(), + cw20_contract.clone(), + &cw20::Cw20ExecuteMsg::Mint { + recipient: receiver.to_string(), + amount: Uint128::new(amount), + }, + &[], + ) + .unwrap(); +} diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs b/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs new file mode 100644 index 000000000..2cb48f244 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs @@ -0,0 +1,829 @@ +use cosmwasm_std::{to_binary, Addr, Coin, Empty, Uint128}; +use cw20::Cw20Coin; +use cw_multi_test::{next_block, App, BankSudo, ContractWrapper, Executor, SudoMsg}; +use cw_utils::Duration; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_pre_propose_multiple as cppm; +use dao_testing::contracts::{ + cw20_balances_voting_contract, cw20_base_contract, cw20_stake_contract, + cw20_staked_balances_voting_contract, cw4_group_contract, cw721_base_contract, + dao_dao_contract, native_staked_balances_voting_contract, pre_propose_multiple_contract, +}; +use dao_voting::{ + deposit::{DepositRefundPolicy, UncheckedDepositInfo}, + multiple_choice::VotingStrategy, + pre_propose::PreProposeInfo, + threshold::{ActiveThreshold, ActiveThreshold::AbsoluteCount, PercentageThreshold}, +}; +use dao_voting_cw4::msg::GroupContract; + +use crate::testing::tests::ALTERNATIVE_ADDR; +use crate::{ + msg::InstantiateMsg, testing::tests::proposal_multiple_contract, testing::tests::CREATOR_ADDR, +}; + +#[allow(dead_code)] +fn get_pre_propose_info( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> PreProposeInfo { + let pre_propose_contract = app.store_code(pre_propose_multiple_contract()); + PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_contract, + msg: to_binary(&cppm::InstantiateMsg { + deposit_info, + open_proposal_submission, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "pre_propose_contract".to_string(), + }, + } +} + +pub fn _get_default_token_dao_proposal_module_instantiate(app: &mut App) -> InstantiateMsg { + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + + InstantiateMsg { + voting_strategy, + max_voting_period: Duration::Time(604800), // One week. + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info( + app, + Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(10_000_000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + close_proposal_on_execution_failure: true, + } +} + +// Same as above but no proposal deposit. +fn _get_default_non_token_dao_proposal_module_instantiate(app: &mut App) -> InstantiateMsg { + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + + InstantiateMsg { + voting_strategy, + max_voting_period: Duration::Time(604800), // One week. + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info(app, None, false), + close_proposal_on_execution_failure: true, + } +} + +pub fn _instantiate_with_staked_cw721_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_multiple_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let cw721_id = app.store_code(cw721_base_contract()); + let cw721_stake_id = app.store_code({ + let contract = ContractWrapper::new( + dao_voting_cw721_staked::contract::execute, + dao_voting_cw721_staked::contract::instantiate, + dao_voting_cw721_staked::contract::query, + ); + Box::new(contract) + }); + let core_contract_id = app.store_code(dao_dao_contract()); + + let nft_address = app + .instantiate_contract( + cw721_id, + Addr::unchecked("ekez"), + &cw721_base::msg::InstantiateMsg { + minter: "ekez".to_string(), + symbol: "token".to_string(), + name: "ekez token best token".to_string(), + }, + &[], + "nft-staking", + None, + ) + .unwrap(); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: cw721_stake_id, + msg: to_binary(&dao_voting_cw721_staked::msg::InstantiateMsg { + owner: Some(Admin::CoreModule {}), + unstaking_duration: None, + nft_contract: dao_voting_cw721_staked::msg::NftContract::Existing { + address: nft_address.to_string(), + }, + active_threshold: None, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&proposal_module_instantiate).unwrap(), + }], + initial_items: None, + dao_uri: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let core_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let staking_addr = core_state.voting_module; + + for Cw20Coin { address, amount } in initial_balances { + for i in 0..amount.u128() { + app.execute_contract( + Addr::unchecked("ekez"), + nft_address.clone(), + &cw721_base::msg::ExecuteMsg::, Empty>::Mint { + token_id: format!("{address}_{i}"), + owner: address.clone(), + token_uri: None, + extension: None, + }, + &[], + ) + .unwrap(); + app.execute_contract( + Addr::unchecked(address.clone()), + nft_address.clone(), + &cw721_base::msg::ExecuteMsg::, Empty>::SendNft { + contract: staking_addr.to_string(), + token_id: format!("{address}_{i}"), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap(); + } + } + + // Update the block so that staked balances appear. + app.update_block(|block| block.height += 1); + + core_addr +} + +pub fn _instantiate_with_native_staked_balances_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_multiple_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + // Collapse balances so that we can test double votes. + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let native_stake_id = app.store_code(native_staked_balances_voting_contract()); + let core_contract_id = app.store_code(dao_dao_contract()); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: native_stake_id, + msg: to_binary(&dao_voting_native_staked::msg::InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: None, + denom: "ujuno".to_string(), + unstaking_duration: None, + active_threshold: None, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&proposal_module_instantiate).unwrap(), + }], + initial_items: None, + dao_uri: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let native_staking_addr = gov_state.voting_module; + + for Cw20Coin { address, amount } in initial_balances { + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: address.clone(), + amount: vec![Coin { + denom: "ujuno".to_string(), + amount, + }], + })) + .unwrap(); + app.execute_contract( + Addr::unchecked(&address), + native_staking_addr.clone(), + &dao_voting_native_staked::msg::ExecuteMsg::Stake {}, + &[Coin { + amount, + denom: "ujuno".to_string(), + }], + ) + .unwrap(); + } + + app.update_block(next_block); + + core_addr +} + +pub fn instantiate_with_cw20_balances_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_multiple_contract()); + + let cw20_id = app.store_code(cw20_base_contract()); + let core_id = app.store_code(dao_dao_contract()); + let votemod_id = app.store_code(cw20_balances_voting_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + // Collapse balances so that we can test double votes. + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances, + marketing: None, + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&proposal_module_instantiate).unwrap(), + }], + initial_items: None, + dao_uri: None, + }; + + app.instantiate_contract( + core_id, + Addr::unchecked(CREATOR_ADDR), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap() +} + +pub fn instantiate_with_staked_balances_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_multiple_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + // Collapse balances so that we can test double votes. + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_stake_id = app.store_code(cw20_stake_contract()); + let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); + let core_contract_id = app.store_code(dao_dao_contract()); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: staked_balances_voting_id, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: None, + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token.".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: initial_balances.clone(), + marketing: None, + staking_code_id: cw20_stake_id, + unstaking_duration: Some(Duration::Height(6)), + initial_dao_balance: None, + }, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&proposal_module_instantiate).unwrap(), + }], + initial_items: None, + dao_uri: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let voting_module = gov_state.voting_module; + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Stake all the initial balances. + for Cw20Coin { address, amount } in initial_balances { + app.execute_contract( + Addr::unchecked(address), + token_contract.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount, + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + } + + // Update the block so that those staked balances appear. + app.update_block(|block| block.height += 1); + + core_addr +} + +pub fn instantiate_with_multiple_staked_balances_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_multiple_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![ + Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: ALTERNATIVE_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }, + ] + }); + + // Collapse balances so that we can test double votes. + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_stake_id = app.store_code(cw20_stake_contract()); + let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); + let core_contract_id = app.store_code(dao_dao_contract()); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: staked_balances_voting_id, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: Some(AbsoluteCount { + count: Uint128::one(), + }), + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token.".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: initial_balances.clone(), + marketing: None, + staking_code_id: cw20_stake_id, + unstaking_duration: Some(Duration::Height(6)), + initial_dao_balance: None, + }, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&proposal_module_instantiate).unwrap(), + }], + initial_items: None, + dao_uri: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let voting_module = gov_state.voting_module; + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Stake all the initial balances. + for Cw20Coin { address, amount } in initial_balances { + app.execute_contract( + Addr::unchecked(address), + token_contract.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount, + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + } + + // Update the block so that those staked balances appear. + app.update_block(|block| block.height += 1); + + core_addr +} + +pub fn instantiate_with_staking_active_threshold( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, + active_threshold: Option, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_multiple_contract()); + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_staking_id = app.store_code(cw20_stake_contract()); + let core_id = app.store_code(dao_dao_contract()); + let votemod_id = app.store_code(cw20_staked_balances_voting_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances, + marketing: None, + staking_code_id: cw20_staking_id, + unstaking_duration: None, + initial_dao_balance: None, + }, + active_threshold, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + msg: to_binary(&proposal_module_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + dao_uri: None, + }; + + app.instantiate_contract( + core_id, + Addr::unchecked(CREATOR_ADDR), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap() +} + +pub fn _instantiate_with_cw4_groups_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_weights: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_multiple_contract()); + let cw4_id = app.store_code(cw4_group_contract()); + let core_id = app.store_code(dao_dao_contract()); + let votemod_id = app.store_code(cw4_group_contract()); + + let initial_weights = initial_weights.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(1), + }] + }); + + // Remove duplicates so that we can test duplicate voting. + let initial_weights: Vec = { + let mut already_seen = vec![]; + initial_weights + .into_iter() + .filter(|Cw20Coin { address, .. }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .map(|Cw20Coin { address, amount }| cw4::Member { + addr: address, + weight: amount.u128() as u64, + }) + .collect() + }; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw4::msg::InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members: initial_weights, + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + msg: to_binary(&proposal_module_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + dao_uri: None, + }; + + let addr = app + .instantiate_contract( + core_id, + Addr::unchecked(CREATOR_ADDR), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + // Update the block so that weights appear. + app.update_block(|block| block.height += 1); + + addr +} diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/mod.rs b/contracts/proposal/dao-proposal-multiple/src/testing/mod.rs new file mode 100644 index 000000000..6c0a224c7 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/testing/mod.rs @@ -0,0 +1,6 @@ +pub mod adversarial_tests; +pub mod do_votes; +pub mod execute; +pub mod instantiate; +pub mod queries; +pub mod tests; diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/queries.rs b/contracts/proposal/dao-proposal-multiple/src/testing/queries.rs new file mode 100644 index 000000000..da528383d --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/testing/queries.rs @@ -0,0 +1,177 @@ +use cosmwasm_std::{Addr, Uint128}; +use cw_hooks::HooksResponse; +use cw_multi_test::App; +use dao_interface::state::{ProposalModule, ProposalModuleStatus}; +use dao_pre_propose_multiple as cppm; +use dao_voting::pre_propose::ProposalCreationPolicy; + +use crate::{ + msg::QueryMsg, + query::{ProposalListResponse, ProposalResponse}, + state::Config, +}; + +pub fn query_deposit_config_and_pre_propose_module( + app: &App, + proposal_multiple: &Addr, +) -> (cppm::Config, Addr) { + let proposal_creation_policy = query_creation_policy(app, proposal_multiple); + + if let ProposalCreationPolicy::Module { addr: module_addr } = proposal_creation_policy { + let deposit_config = query_pre_proposal_multiple_config(app, &module_addr); + + (deposit_config, module_addr) + } else { + panic!("no pre-propose module.") + } +} + +pub fn query_proposal_config(app: &App, proposal_multiple: &Addr) -> Config { + app.wrap() + .query_wasm_smart(proposal_multiple, &QueryMsg::Config {}) + .unwrap() +} + +pub fn query_creation_policy(app: &App, proposal_multiple: &Addr) -> ProposalCreationPolicy { + app.wrap() + .query_wasm_smart(proposal_multiple, &QueryMsg::ProposalCreationPolicy {}) + .unwrap() +} + +pub fn query_pre_proposal_multiple_config(app: &App, pre_propose: &Addr) -> cppm::Config { + app.wrap() + .query_wasm_smart(pre_propose, &cppm::QueryMsg::Config {}) + .unwrap() +} + +pub fn query_multiple_proposal_module(app: &App, core_addr: &Addr) -> Addr { + let modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr, + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + // Filter out disabled modules. + let modules = modules + .into_iter() + .filter(|module| module.status == ProposalModuleStatus::Enabled) + .collect::>(); + + assert_eq!( + modules.len(), + 1, + "wrong proposal module count. expected 1, got {}", + modules.len() + ); + + modules.into_iter().next().unwrap().address +} + +pub fn query_list_proposals( + app: &App, + proposal_multiple: &Addr, + start_after: Option, + limit: Option, +) -> ProposalListResponse { + app.wrap() + .query_wasm_smart( + proposal_multiple, + &QueryMsg::ListProposals { start_after, limit }, + ) + .unwrap() +} + +pub fn query_proposal_hooks(app: &App, proposal_multiple: &Addr) -> HooksResponse { + app.wrap() + .query_wasm_smart(proposal_multiple, &QueryMsg::ProposalHooks {}) + .unwrap() +} + +pub fn query_vote_hooks(app: &App, proposal_multiple: &Addr) -> HooksResponse { + app.wrap() + .query_wasm_smart(proposal_multiple, &QueryMsg::VoteHooks {}) + .unwrap() +} + +pub fn query_list_proposals_reverse( + app: &App, + proposal_multiple: &Addr, + start_before: Option, + limit: Option, +) -> ProposalListResponse { + app.wrap() + .query_wasm_smart( + proposal_multiple, + &QueryMsg::ReverseProposals { + start_before, + limit, + }, + ) + .unwrap() +} + +pub fn query_dao_token(app: &App, core_addr: &Addr) -> Addr { + let voting_module = query_voting_module(app, core_addr); + app.wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap() +} + +pub fn query_voting_module(app: &App, core_addr: &Addr) -> Addr { + app.wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap() +} + +pub fn query_cw20_token_staking_contracts(app: &App, core_addr: &Addr) -> (Addr, Addr) { + let voting_module: Addr = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::TokenContract {}, + ) + .unwrap(); + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + (token_contract, staking_contract) +} + +pub fn query_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +pub fn query_balance_native(app: &App, who: &str, denom: &str) -> Uint128 { + let res = app.wrap().query_balance(who, denom).unwrap(); + res.amount +} + +pub fn query_proposal(app: &App, proposal_multiple: &Addr, id: u64) -> ProposalResponse { + app.wrap() + .query_wasm_smart(proposal_multiple, &QueryMsg::Proposal { proposal_id: id }) + .unwrap() +} diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs new file mode 100644 index 000000000..eaf69fcc8 --- /dev/null +++ b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs @@ -0,0 +1,4500 @@ +use cosmwasm_std::{to_binary, Addr, Coin, CosmosMsg, Decimal, Empty, Timestamp, Uint128, WasmMsg}; +use cw20::Cw20Coin; +use cw_denom::{CheckedDenom, UncheckedDenom}; +use cw_hooks::HooksResponse; +use cw_multi_test::{next_block, App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; +use cw_utils::Duration; +use dao_interface::state::ProposalModule; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_voting::{ + deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, + multiple_choice::{ + CheckedMultipleChoiceOption, MultipleChoiceOption, MultipleChoiceOptionType, + MultipleChoiceOptions, MultipleChoiceVote, MultipleChoiceVotes, VotingStrategy, + MAX_NUM_CHOICES, + }, + pre_propose::PreProposeInfo, + status::Status, + threshold::{ActiveThreshold, PercentageThreshold, Threshold}, +}; +use std::panic; + +use crate::{ + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + proposal::MultipleChoiceProposal, + query::{ProposalListResponse, ProposalResponse, VoteInfo, VoteListResponse, VoteResponse}, + state::Config, + testing::{ + do_votes::do_test_votes_cw20_balances, + execute::make_proposal, + instantiate::{ + instantiate_with_cw20_balances_governance, instantiate_with_staked_balances_governance, + instantiate_with_staking_active_threshold, + }, + queries::{ + query_balance_cw20, query_balance_native, query_cw20_token_staking_contracts, + query_dao_token, query_deposit_config_and_pre_propose_module, query_list_proposals, + query_list_proposals_reverse, query_multiple_proposal_module, query_proposal, + query_proposal_config, query_proposal_hooks, query_vote_hooks, + }, + }, + ContractError, +}; +use dao_pre_propose_multiple as cppm; + +use dao_testing::{ + contracts::{cw20_balances_voting_contract, cw20_base_contract}, + ShouldExecute, +}; + +pub const CREATOR_ADDR: &str = "creator"; +pub const ALTERNATIVE_ADDR: &str = "alternative"; + +pub struct TestMultipleChoiceVote { + /// The address casting the vote. + pub voter: String, + /// Position on the vote. + pub position: MultipleChoiceVote, + /// Voting power of the address. + pub weight: Uint128, + /// If this vote is expected to execute. + pub should_execute: ShouldExecute, +} + +pub fn proposal_multiple_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply); + Box::new(contract) +} + +pub fn pre_propose_multiple_contract() -> Box> { + let contract = ContractWrapper::new( + cppm::contract::execute, + cppm::contract::instantiate, + cppm::contract::query, + ); + Box::new(contract) +} + +pub fn get_pre_propose_info( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> PreProposeInfo { + let pre_propose_contract = app.store_code(pre_propose_multiple_contract()); + PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_contract, + msg: to_binary(&cppm::InstantiateMsg { + deposit_info, + open_proposal_submission, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "pre_propose_contract".to_string(), + }, + } +} + +#[test] +fn test_propose() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let max_voting_period = Duration::Height(6); + let quorum = PercentageThreshold::Majority {}; + + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + + let instantiate = InstantiateMsg { + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy: voting_strategy.clone(), + min_voting_period: None, + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + // Check that the config has been configured correctly. + let config: Config = query_proposal_config(&app, &govmod); + let expected = Config { + max_voting_period, + only_members_execute: false, + allow_revoting: false, + dao: core_addr, + voting_strategy: voting_strategy.clone(), + min_voting_period: None, + close_proposal_on_execution_failure: true, + }; + assert_eq!(config, expected); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + // Create a new proposal. + make_proposal(&mut app, &govmod, CREATOR_ADDR, mc_options.clone()); + + let created: ProposalResponse = query_proposal(&app, &govmod, 1); + + let current_block = app.block_info(); + let checked_options = mc_options.into_checked().unwrap(); + let expected = MultipleChoiceProposal { + title: "title".to_string(), + description: "description".to_string(), + proposer: Addr::unchecked(CREATOR_ADDR), + start_height: current_block.height, + expiration: max_voting_period.after(¤t_block), + choices: checked_options.options, + status: Status::Open, + voting_strategy, + total_power: Uint128::new(100_000_000), + votes: MultipleChoiceVotes { + vote_weights: vec![Uint128::zero(); 3], + }, + allow_revoting: false, + min_voting_period: None, + }; + + assert_eq!(created.proposal, expected); + assert_eq!(created.id, 1u64); +} + +#[test] +fn test_propose_wrong_num_choices() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let max_voting_period = cw_utils::Duration::Height(6); + let quorum = PercentageThreshold::Majority {}; + + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy: voting_strategy.clone(), + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + // Check that the config has been configured correctly. + let config: Config = query_proposal_config(&app, &govmod); + let expected = Config { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + dao: core_addr, + voting_strategy, + }; + assert_eq!(config, expected); + + let options = vec![]; + + // Create a proposal with less than min choices. + let mc_options = MultipleChoiceOptions { options }; + let err = app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ); + assert!(err.is_err()); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }; + std::convert::TryInto::try_into(MAX_NUM_CHOICES + 1).unwrap() + ]; + + // Create proposal with more than max choices. + + let mc_options = MultipleChoiceOptions { options }; + // Create a new proposal. + let err = app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod, + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ); + assert!(err.is_err()); +} + +#[test] +fn test_proposal_count_initialized_to_zero() { + let mut app = App::default(); + let _proposal_id = app.store_code(proposal_multiple_contract()); + let msg = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Height(10), + min_voting_period: None, + close_proposal_on_execution_failure: true, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + let core_addr = instantiate_with_staked_balances_governance(&mut app, msg, None); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_modules = gov_state.proposal_modules; + + assert_eq!(proposal_modules.len(), 1); + let govmod = proposal_modules.into_iter().next().unwrap().address; + + let proposal_count: u64 = app + .wrap() + .query_wasm_smart(govmod, &QueryMsg::ProposalCount {}) + .unwrap(); + + assert_eq!(proposal_count, 0); +} + +#[test] +fn test_no_early_pass_with_min_duration() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let msg = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Height(10), + min_voting_period: Some(Duration::Height(2)), + only_members_execute: true, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + msg, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "whale".to_string(), + amount: Uint128::new(90), + }, + ]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_modules = gov_state.proposal_modules; + + assert_eq!(proposal_modules.len(), 1); + let govmod = proposal_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + app.execute_contract( + Addr::unchecked("whale"), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // Whale votes which under normal curcumstances would cause the + // proposal to pass. Because there is a min duration it does not. + app.execute_contract( + Addr::unchecked("whale"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + + assert_eq!(proposal.proposal.status, Status::Open); + + // Let the min voting period pass. + app.update_block(|b| b.height += 2); + + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + + assert_eq!(proposal.proposal.status, Status::Passed); +} + +#[test] +fn test_propose_with_messages() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let msg = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Height(10), + min_voting_period: None, + close_proposal_on_execution_failure: true, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + msg, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "whale".to_string(), + amount: Uint128::new(90), + }, + ]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_modules = gov_state.proposal_modules; + + assert_eq!(proposal_modules.len(), 1); + let govmod = proposal_modules.into_iter().next().unwrap().address; + + let config_msg = ExecuteMsg::UpdateConfig { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period: cw_utils::Duration::Height(20), + only_members_execute: false, + allow_revoting: false, + dao: "dao".to_string(), + }; + + let wasm_msg = WasmMsg::Execute { + contract_addr: govmod.to_string(), + msg: to_binary(&config_msg).unwrap(), + funds: vec![], + }; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![CosmosMsg::Wasm(wasm_msg)], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + app.execute_contract( + Addr::unchecked("whale"), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked("whale"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + + assert_eq!(proposal.proposal.status, Status::Passed); + + // Execute the proposal and messages + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Check that config was updated by proposal message + let config: Config = query_proposal_config(&app, &govmod); + assert_eq!(config.max_voting_period, Duration::Height(20)) +} + +#[test] +#[should_panic( + expected = "min_voting_period and max_voting_period must have the same units (height or time)" +)] +fn test_min_duration_units_missmatch() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let msg = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Height(10), + min_voting_period: Some(Duration::Time(2)), + only_members_execute: true, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + instantiate_with_staked_balances_governance( + &mut app, + msg, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "wale".to_string(), + amount: Uint128::new(90), + }, + ]), + ); +} + +#[test] +#[should_panic(expected = "Min voting period must be less than or equal to max voting period")] +fn test_min_duration_larger_than_proposal_duration() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let msg = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Height(10), + min_voting_period: Some(Duration::Height(11)), + only_members_execute: true, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + instantiate_with_staked_balances_governance( + &mut app, + msg, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "wale".to_string(), + amount: Uint128::new(90), + }, + ]), + ); +} + +#[test] +fn test_min_duration_same_as_proposal_duration() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let msg = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Time(10), + min_voting_period: Some(Duration::Time(10)), + only_members_execute: true, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + msg, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "whale".to_string(), + amount: Uint128::new(90), + }, + ]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_modules = gov_state.proposal_modules; + + assert_eq!(proposal_modules.len(), 1); + let govmod = proposal_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + app.execute_contract( + Addr::unchecked("whale"), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // Whale votes which under normal curcumstances would cause the + // proposal to pass. Because there is a min duration it does not. + app.execute_contract( + Addr::unchecked("whale"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + + assert_eq!(proposal.proposal.status, Status::Open); + + // someone else can vote none of the above. + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 2 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Let the min voting period pass. + app.update_block(|b| b.time = b.time.plus_seconds(10)); + + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + + assert_eq!(proposal.proposal.status, Status::Passed); +} + +/// Instantiate the contract and use the voting module's token +/// contract as the proposal deposit token. +#[test] +fn test_voting_module_token_proposal_deposit_instantiate() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + }; + + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let token = query_dao_token(&app, &core_addr); + + let (deposit_config, _) = query_deposit_config_and_pre_propose_module(&app, &govmod); + assert_eq!( + deposit_config.deposit_info, + Some(CheckedDepositInfo { + denom: CheckedDenom::Cw20(token), + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed + }) + ) +} + +// Instantiate the contract and use a cw20 unrealated to the voting +// module for the proposal deposit. +#[test] +fn test_different_token_proposal_deposit() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_addr = app + .instantiate_contract( + cw20_id, + Addr::unchecked(CREATOR_ADDR), + &cw20_base::msg::InstantiateMsg { + name: "OAD OAD".to_string(), + symbol: "OAD".to_string(), + decimals: 6, + initial_balances: vec![], + mint: None, + marketing: None, + }, + &[], + "random-cw20", + None, + ) + .unwrap(); + + let quorum = PercentageThreshold::Percent(Decimal::percent(10)); + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(cw20_addr.to_string()), + }, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + }; + + instantiate_with_staked_balances_governance(&mut app, instantiate, None); +} + +/// Try to instantiate the governance module with a non-cw20 as its +/// proposal deposit token. This should error as the `TokenInfo {}` +/// query ought to fail. +#[test] +#[should_panic(expected = "Error parsing into type dao_voting_cw20_balance::msg::QueryMsg")] +fn test_bad_token_proposal_deposit() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let cw20_id = app.store_code(cw20_base_contract()); + let votemod_id = app.store_code(cw20_balances_voting_contract()); + + let votemod_addr = app + .instantiate_contract( + votemod_id, + Addr::unchecked(CREATOR_ADDR), + &dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(1), + }], + marketing: None, + }, + }, + &[], + "random-vote-module", + None, + ) + .unwrap(); + + let quorum = PercentageThreshold::Percent(Decimal::percent(10)); + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Cw20(votemod_addr.to_string()), + }, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + }; + + instantiate_with_staked_balances_governance(&mut app, instantiate, None); +} + +#[test] +fn test_take_proposal_deposit() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let quorum = PercentageThreshold::Percent(Decimal::percent(10)); + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + }; + + let core_addr = instantiate_with_cw20_balances_governance( + &mut app, + instantiate, + Some(vec![Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(2), + }]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + let (deposit_config, pre_propose_module) = + query_deposit_config_and_pre_propose_module(&app, &govmod); + if let CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + .. + } = deposit_config.deposit_info.unwrap() + { + app.execute_contract( + Addr::unchecked("blue"), + pre_propose_module, + &cppm::ExecuteMsg::Propose { + msg: cppm::ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + choices: mc_options.clone(), + }, + }, + &[], + ) + .unwrap_err(); + + // Allow a proposal deposit. + app.execute_contract( + Addr::unchecked("blue"), + Addr::unchecked(token), + &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + spender: govmod.to_string(), + amount: Uint128::new(1), + expires: None, + }, + &[], + ) + .unwrap(); + + make_proposal(&mut app, &govmod, "blue", mc_options); + + // Proposal has been executed so deposit has been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(1)); + } else { + panic!() + }; +} + +#[test] +fn test_native_proposal_deposit() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + max_voting_period, + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::Token { + denom: UncheckedDenom::Native("ujuno".to_string()), + }, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ), + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(2), + }]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let (deposit_config, pre_propose_module) = + query_deposit_config_and_pre_propose_module(&app, &govmod); + if let CheckedDepositInfo { + denom: CheckedDenom::Native(ref _token), + refund_policy, + .. + } = deposit_config.deposit_info.unwrap() + { + assert_eq!(refund_policy, DepositRefundPolicy::Always); + + let mc_options = MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ], + }; + + // This will fail because deposit not send + app.execute_contract( + Addr::unchecked("blue"), + pre_propose_module.clone(), + &cppm::ExecuteMsg::Propose { + msg: cppm::ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + choices: mc_options.clone(), + }, + }, + &[], + ) + .unwrap_err(); + + // Mint blue some tokens + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: "blue".to_string(), + amount: vec![Coin { + denom: "ujuno".to_string(), + amount: Uint128::new(100), + }], + })) + .unwrap(); + + // Adding deposit will work + make_proposal(&mut app, &govmod, "blue", mc_options); + + // "blue" has been refunded + let balance = query_balance_native(&app, "blue", "ujuno"); + assert_eq!(balance, Uint128::new(99)); + + // Govmod has refunded the token + let balance = query_balance_native(&app, pre_propose_module.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(1)); + + // Vote on the proposal. + let res = app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 1 }, + rationale: None, + }, + &[], + ); + assert!(res.is_ok()); + + // Execute the proposal, this should cause the deposit to be + // refunded. + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // "blue" has been refunded + let balance = query_balance_native(&app, "blue", "ujuno"); + assert_eq!(balance, Uint128::new(100)); + + // Govmod has refunded the token + let balance = query_balance_native(&app, pre_propose_module.as_str(), "ujuno"); + assert_eq!(balance, Uint128::new(0)); + } else { + panic!() + }; +} + +#[test] +fn test_deposit_return_on_execute() { + // Will create a proposal and execute it, one token will be + // deposited to create said proposal, expectation is that the + // token is then returned once the proposal is executed. + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + true, + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + // Ger deposit info + let (deposit_config, _) = query_deposit_config_and_pre_propose_module(&app, &govmod); + if let CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + .. + } = deposit_config.deposit_info.unwrap() + { + // Proposal has not been executed so deposit has not been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(9)); + + // Execute the proposal, this should cause the deposit to be + // refunded. + app.execute_contract( + Addr::unchecked("blue"), + govmod, + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Proposal has been executed so deposit has been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(10)); + } else { + panic!() + }; +} + +#[test] +fn test_deposit_return_zero() { + // Test that balance does not change when deposit is zero. + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + None, + true, + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let token = query_dao_token(&app, &core_addr); + + // Execute the proposal, this should cause the deposit to be + // refunded. + app.execute_contract( + Addr::unchecked("blue"), + govmod, + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Proposal has been executed so deposit has been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(10)); +} + +#[test] +fn test_query_list_votes() { + let (app, core_addr) = do_test_votes_cw20_balances( + vec![ + TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestMultipleChoiceVote { + voter: "note".to_string(), + position: MultipleChoiceVote { option_id: 1 }, + weight: Uint128::new(20), + should_execute: ShouldExecute::Yes, + }, + ], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + None, + true, + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let list_votes: VoteListResponse = app + .wrap() + .query_wasm_smart( + govmod, + &QueryMsg::ListVotes { + proposal_id: 1, + start_after: None, + limit: None, + }, + ) + .unwrap(); + + let expected = vec![ + VoteInfo { + voter: Addr::unchecked("blue"), + vote: MultipleChoiceVote { option_id: 0 }, + power: Uint128::new(10), + rationale: None, + }, + VoteInfo { + voter: Addr::unchecked("note"), + vote: MultipleChoiceVote { option_id: 1 }, + power: Uint128::new(20), + rationale: None, + }, + ]; + + assert_eq!(list_votes.votes, expected) +} + +#[test] +fn test_invalid_quorum() { + // Create a proposal that will be rejected + let (_app, _core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::from_ratio(1u128, 10u128)), + }, + Status::Rejected, + None, + None, + true, + ); +} + +#[test] +fn test_cant_vote_executed_or_closed() { + // Create a proposal that will be rejected + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Rejected, + None, + None, + true, + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + // Close the proposal + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Try to vote, should error + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap_err(); + + // Create a proposal that will pass + let (mut app, _core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + None, + true, + ); + + // Execute the proposal + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Try to vote, should error + app.execute_contract( + Addr::unchecked("blue"), + govmod, + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap_err(); +} + +#[test] +fn test_cant_propose_zero_power() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let quorum = PercentageThreshold::Percent(Decimal::percent(10)); + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ), + }; + + let core_addr = instantiate_with_cw20_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "blue2".to_string(), + amount: Uint128::new(10), + }, + ]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_modules = gov_state.proposal_modules; + + assert_eq!(proposal_modules.len(), 1); + let govmod = proposal_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + let (deposit_config, pre_propose_module) = + query_deposit_config_and_pre_propose_module(&app, &govmod); + if let Some(CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + amount, + .. + }) = deposit_config.deposit_info + { + app.execute_contract( + Addr::unchecked("blue"), + token.clone(), + &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + spender: pre_propose_module.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); + } + + // Blue proposes + app.execute_contract( + Addr::unchecked("blue"), + pre_propose_module.clone(), + &cppm::ExecuteMsg::Propose { + msg: cppm::ProposeMessage::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options.clone(), + }, + }, + &[], + ) + .unwrap(); + + // Should fail as blue's balance is now 0 + let err = app.execute_contract( + Addr::unchecked("blue"), + pre_propose_module, + &cppm::ExecuteMsg::Propose { + msg: cppm::ProposeMessage::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + }, + }, + &[], + ); + + assert!(err.is_err()) +} + +#[test] +fn test_cant_vote_not_registered() { + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Open, + Some(Uint128::new(100)), + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + // Should error as blue2 is not registered to vote + let err = app + .execute_contract( + Addr::unchecked("blue2"), + govmod, + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap_err(); + + assert!(matches!( + err.downcast().unwrap(), + ContractError::NotRegistered {} + )) +} + +#[test] +fn test_cant_execute_not_member() { + // Create proposal with only_members_execute: true + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let max_voting_period = cw_utils::Duration::Height(6); + let quorum = PercentageThreshold::Majority {}; + + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: true, + allow_revoting: false, + voting_strategy, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(10), + }]), + ); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + // Create proposal + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // Proposal should pass after this vote + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Execute should error as blue2 is not a member + let err = app + .execute_contract( + Addr::unchecked("blue2"), + govmod, + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap_err(); + + assert!(matches!( + err.downcast().unwrap(), + ContractError::Unauthorized {} + )) +} + +#[test] +fn test_cant_execute_not_member_when_proposal_created() { + // Create proposal with only_members_execute: true and ensure member cannot + // execute if they were not a member when the proposal was created + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let max_voting_period = cw_utils::Duration::Height(6); + let quorum = PercentageThreshold::Majority {}; + + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: true, + allow_revoting: false, + voting_strategy, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(10), + }]), + ); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + // Create proposal + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // Proposal should pass after this vote + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + let (token_contract, staking_contract) = query_cw20_token_staking_contracts(&app, &core_addr); + // Mint funds for blue2 + app.execute_contract( + core_addr, + token_contract.clone(), + &cw20::Cw20ExecuteMsg::Mint { + recipient: "blue2".to_string(), + amount: Uint128::new(10), + }, + &[], + ) + .unwrap(); + // Have blue2 stake funds + app.execute_contract( + Addr::unchecked("blue2"), + token_contract, + &cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(10), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + + // Update the block so that the staked balance appears. + app.update_block(|block| block.height += 1); + + // Execute should error as blue2 was not a member when the proposal was + // created even though they are now + let err = app + .execute_contract( + Addr::unchecked("blue2"), + govmod, + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap_err(); + + assert!(matches!( + err.downcast().unwrap(), + ContractError::Unauthorized {} + )) +} + +#[test] +fn test_open_proposal_submission() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let max_voting_period = cw_utils::Duration::Height(6); + + // Instantiate with open_proposal_submission enabled + let instantiate = InstantiateMsg { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + max_voting_period, + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info: get_pre_propose_info(&mut app, None, true), + }; + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + make_proposal( + &mut app, + &govmod, + "random", + MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ], + }, + ); + + let created: ProposalResponse = query_proposal(&app, &govmod, 1); + let current_block = app.block_info(); + let expected = MultipleChoiceProposal { + title: "title".to_string(), + description: "description".to_string(), + proposer: Addr::unchecked("random"), + start_height: current_block.height, + expiration: max_voting_period.after(¤t_block), + min_voting_period: None, + allow_revoting: false, + total_power: Uint128::new(100_000_000), + status: Status::Open, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Percent(Decimal::percent(100)), + }, + choices: vec![ + CheckedMultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + option_type: MultipleChoiceOptionType::Standard, + vote_count: Uint128::zero(), + index: 0, + title: "title".to_string(), + }, + CheckedMultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + option_type: MultipleChoiceOptionType::Standard, + vote_count: Uint128::zero(), + index: 1, + title: "title".to_string(), + }, + CheckedMultipleChoiceOption { + description: "None of the above".to_string(), + msgs: vec![], + option_type: MultipleChoiceOptionType::None, + vote_count: Uint128::zero(), + index: 2, + title: "None of the above".to_string(), + }, + ], + votes: MultipleChoiceVotes { + vote_weights: vec![Uint128::zero(); 3], + }, + }; + + assert_eq!(created.proposal, expected); + assert_eq!(created.id, 1u64); +} + +#[test] +fn test_close_open_proposal() { + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Open, + Some(Uint128::new(100)), + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + // Close the proposal, this should error as the proposal is still + // open and not expired. + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap_err(); + + // Make the proposal expire. + app.update_block(|block| block.height += 10); + + // Close the proposal, this should work as the proposal is now + // open and expired. + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap(); + + let (deposit_config, _) = query_deposit_config_and_pre_propose_module(&app, &govmod); + if let CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + .. + } = deposit_config.deposit_info.unwrap() + { + // Proposal has been executed so deposit has been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(10)); + } else { + panic!() + }; +} + +#[test] +fn test_no_refund_failed_proposal() { + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Open, + Some(Uint128::new(100)), + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + // Make the proposal expire. + app.update_block(|block| block.height += 10); + + // Close the proposal, this should work as the proposal is now + // open and expired. + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap(); + + let (deposit_config, _) = query_deposit_config_and_pre_propose_module(&app, &govmod); + if let CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + .. + } = deposit_config.deposit_info.unwrap() + { + // Proposal has been executed so deposit has been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(9)); + } else { + panic!() + }; +} + +#[test] +fn test_zero_deposit() { + do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + None, + true, + ); +} + +#[test] +fn test_deposit_return_on_close() { + let quorum = PercentageThreshold::Percent(Decimal::percent(10)); + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + voting_strategy, + Status::Rejected, + None, + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::Always, + }), + false, + ); + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let (deposit_config, _) = query_deposit_config_and_pre_propose_module(&app, &govmod); + if let CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + .. + } = deposit_config.deposit_info.unwrap() + { + // Proposal has been executed so deposit has been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(9)); + + // Close the proposal, this should cause the deposit to be + // refunded. + app.execute_contract( + Addr::unchecked("blue"), + govmod, + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Proposal has been executed so deposit has been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(10)); + } else { + panic!() + }; +} + +#[test] +fn test_execute_expired_proposal() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let quorum = PercentageThreshold::Percent(Decimal::percent(10)); + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "inactive".to_string(), + amount: Uint128::new(90), + }, + ]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_modules = gov_state.proposal_modules; + + assert_eq!(proposal_modules.len(), 1); + let govmod = proposal_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Proposal has now reached quorum but should not be passed. + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + assert_eq!(proposal.proposal.status, Status::Open); + + // Expire the proposal. It should now be passed as quorum was reached. + app.update_block(|b| b.height += 10); + + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + assert_eq!(proposal.proposal.status, Status::Passed); + + // Try to close the proposal. This should fail as the proposal is + // passed. + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap_err(); + + // Check that we can execute the proposal despite the fact that it + // is technically expired. + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Can't execute more than once. + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap_err(); + + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + assert_eq!(proposal.proposal.status, Status::Executed); +} + +#[test] +fn test_update_config() { + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 0 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + None, + false, + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let govmod_config: Config = query_proposal_config(&app, &govmod); + + assert_eq!( + govmod_config.voting_strategy, + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {} + } + ); + + let dao = govmod_config.dao; + + // Attempt to update the config from a non-dao address. This + // should fail as it is unauthorized. + app.execute_contract( + Addr::unchecked("wrong"), + govmod.clone(), + &ExecuteMsg::UpdateConfig { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period: cw_utils::Duration::Height(10), + only_members_execute: false, + allow_revoting: false, + dao: dao.to_string(), + }, + &[], + ) + .unwrap_err(); + + // Update the config from the DAO address. This should succeed. + app.execute_contract( + dao.clone(), + govmod.clone(), + &ExecuteMsg::UpdateConfig { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period: cw_utils::Duration::Height(10), + only_members_execute: false, + allow_revoting: false, + dao: Addr::unchecked(CREATOR_ADDR).to_string(), + }, + &[], + ) + .unwrap(); + + let govmod_config: Config = query_proposal_config(&app, &govmod); + + let expected = Config { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period: cw_utils::Duration::Height(10), + only_members_execute: false, + allow_revoting: false, + dao: Addr::unchecked(CREATOR_ADDR), + }; + assert_eq!(govmod_config, expected); + + // As we have changed the DAO address updating the config using + // the original one should now fail. + app.execute_contract( + dao, + govmod, + &ExecuteMsg::UpdateConfig { + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period: cw_utils::Duration::Height(10), + only_members_execute: false, + allow_revoting: false, + dao: Addr::unchecked(CREATOR_ADDR).to_string(), + }, + &[], + ) + .unwrap_err(); +} + +#[test] +fn test_no_return_if_no_refunds() { + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Rejected, + None, + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + true, + ); + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let (deposit_config, _) = query_deposit_config_and_pre_propose_module(&app, &govmod); + if let CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + .. + } = deposit_config.deposit_info.unwrap() + { + // Close the proposal, this should cause the deposit to be + // refunded. + app.execute_contract( + Addr::unchecked("blue"), + govmod, + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap(); + + // Proposal has been executed so deposit has been refunded. + let balance = query_balance_cw20(&app, token, "blue".to_string()); + assert_eq!(balance, Uint128::new(9)); + } else { + panic!() + }; +} + +#[test] +fn test_query_list_proposals() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy: voting_strategy.clone(), + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + let gov_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100), + }]), + ); + + let gov_modules: Vec = app + .wrap() + .query_wasm_smart( + gov_addr, + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(gov_modules.len(), 1); + + let govmod = gov_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + for _i in 1..10 { + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options.clone(), + proposer: None, + }, + &[], + ) + .unwrap(); + } + + let proposals_forward: ProposalListResponse = query_list_proposals(&app, &govmod, None, None); + let mut proposals_backward: ProposalListResponse = + query_list_proposals_reverse(&app, &govmod, None, None); + + proposals_backward.proposals.reverse(); + + assert_eq!(proposals_forward.proposals, proposals_backward.proposals); + let checked_options = mc_options.into_checked().unwrap(); + let current_block = app.block_info(); + let expected = ProposalResponse { + id: 1, + proposal: MultipleChoiceProposal { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + proposer: Addr::unchecked(CREATOR_ADDR), + start_height: current_block.height, + expiration: max_voting_period.after(¤t_block), + choices: checked_options.options.clone(), + status: Status::Open, + voting_strategy: voting_strategy.clone(), + total_power: Uint128::new(100), + votes: MultipleChoiceVotes { + vote_weights: vec![Uint128::zero(); 3], + }, + allow_revoting: false, + min_voting_period: None, + }, + }; + assert_eq!(proposals_forward.proposals[0], expected); + + // Get proposals (3, 5] + let proposals_forward: ProposalListResponse = + query_list_proposals(&app, &govmod, Some(3), Some(2)); + + let mut proposals_backward: ProposalListResponse = + query_list_proposals_reverse(&app, &govmod, Some(6), Some(2)); + + let expected = ProposalResponse { + id: 4, + proposal: MultipleChoiceProposal { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + proposer: Addr::unchecked(CREATOR_ADDR), + start_height: current_block.height, + expiration: max_voting_period.after(¤t_block), + choices: checked_options.options, + status: Status::Open, + voting_strategy, + total_power: Uint128::new(100), + votes: MultipleChoiceVotes { + vote_weights: vec![Uint128::zero(); 3], + }, + allow_revoting: false, + min_voting_period: None, + }, + }; + assert_eq!(proposals_forward.proposals[0], expected); + assert_eq!(proposals_backward.proposals[1], expected); + + proposals_backward.proposals.reverse(); + assert_eq!(proposals_forward.proposals, proposals_backward.proposals); +} + +#[test] +fn test_hooks() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + let govmod_config: Config = query_proposal_config(&app, &govmod); + let dao = govmod_config.dao; + + // Expect no hooks + let hooks: HooksResponse = query_proposal_hooks(&app, &govmod); + assert_eq!(hooks.hooks.len(), 0); + + let hooks: HooksResponse = query_vote_hooks(&app, &govmod); + assert_eq!(hooks.hooks.len(), 0); + + let msg = ExecuteMsg::AddProposalHook { + address: "some_addr".to_string(), + }; + + // Expect error as sender is not DAO + let _err = app + .execute_contract(Addr::unchecked(CREATOR_ADDR), govmod.clone(), &msg, &[]) + .unwrap_err(); + + // Expect success as sender is now DAO + let _res = app + .execute_contract(dao.clone(), govmod.clone(), &msg, &[]) + .unwrap(); + + let hooks: HooksResponse = query_proposal_hooks(&app, &govmod); + assert_eq!(hooks.hooks.len(), 1); + + // Expect error as hook is already set + let _err = app + .execute_contract(dao.clone(), govmod.clone(), &msg, &[]) + .unwrap_err(); + + // Expect error as hook does not exist + let _err = app + .execute_contract( + dao.clone(), + govmod.clone(), + &ExecuteMsg::RemoveProposalHook { + address: "not_exist".to_string(), + }, + &[], + ) + .unwrap_err(); + + let msg = ExecuteMsg::RemoveProposalHook { + address: "some_addr".to_string(), + }; + + // Expect error as sender is not DAO + let _err = app + .execute_contract(Addr::unchecked(CREATOR_ADDR), govmod.clone(), &msg, &[]) + .unwrap_err(); + + // Expect success + let _res = app + .execute_contract(dao.clone(), govmod.clone(), &msg, &[]) + .unwrap(); + + let msg = ExecuteMsg::AddVoteHook { + address: "some_addr".to_string(), + }; + + // Expect error as sender is not DAO + let _err = app + .execute_contract(Addr::unchecked(CREATOR_ADDR), govmod.clone(), &msg, &[]) + .unwrap_err(); + + // Expect success as sender is now DAO + let _res = app + .execute_contract(dao.clone(), govmod.clone(), &msg, &[]) + .unwrap(); + + let hooks: HooksResponse = query_vote_hooks(&app, &govmod); + assert_eq!(hooks.hooks.len(), 1); + + // Expect error as hook is already set + let _err = app + .execute_contract(dao.clone(), govmod.clone(), &msg, &[]) + .unwrap_err(); + + // Expect error as hook does not exist + let _err = app + .execute_contract( + dao.clone(), + govmod.clone(), + &ExecuteMsg::RemoveVoteHook { + address: "not_exist".to_string(), + }, + &[], + ) + .unwrap_err(); + + let msg = ExecuteMsg::RemoveVoteHook { + address: "some_addr".to_string(), + }; + + // Expect error as sender is not DAO + let _err = app + .execute_contract(Addr::unchecked(CREATOR_ADDR), govmod.clone(), &msg, &[]) + .unwrap_err(); + + // Expect success + let _res = app.execute_contract(dao, govmod, &msg, &[]).unwrap(); +} + +#[test] +fn test_active_threshold_absolute() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staking_active_threshold( + &mut app, + instantiate, + None, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + ); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + let govmod_config: Config = query_proposal_config(&app, &govmod); + let dao = govmod_config.dao; + let voting_module: Addr = app + .wrap() + .query_wasm_smart(dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + // Try and create a proposal, will fail as inactive + let _err = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &crate::msg::ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + choices: mc_options.clone(), + proposer: None, + }, + &[], + ) + .unwrap_err(); + + // Stake enough tokens + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(100), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), token_contract, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // Try and create a proposal, will now succeed as enough tokens are staked + let _res = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &crate::msg::ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + choices: mc_options.clone(), + proposer: None, + }, + &[], + ) + .unwrap(); + + // Unstake some tokens to make it inactive again + let msg = cw20_stake::msg::ExecuteMsg::Unstake { + amount: Uint128::new(50), + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), staking_contract, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // Try and create a proposal, will fail as no longer active + let _err = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod, + &crate::msg::ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap_err(); +} + +#[test] +fn test_active_threshold_percent() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + // 20% needed to be active, 20% of 100000000 is 20000000 + let core_addr = instantiate_with_staking_active_threshold( + &mut app, + instantiate, + None, + Some(ActiveThreshold::Percentage { + percent: Decimal::percent(20), + }), + ); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + let govmod_config: Config = query_proposal_config(&app, &govmod); + let dao = govmod_config.dao; + let voting_module: Addr = app + .wrap() + .query_wasm_smart(dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + // Try and create a proposal, will fail as inactive + let _res = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options.clone(), + proposer: None, + }, + &[], + ) + .unwrap_err(); + + // Stake enough tokens + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(20000000), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), token_contract, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // Try and create a proposal, will now succeed as enough tokens are staked + let _res = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options.clone(), + proposer: None, + }, + &[], + ) + .unwrap(); + + // Unstake some tokens to make it inactive again + let msg = cw20_stake::msg::ExecuteMsg::Unstake { + amount: Uint128::new(1000), + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), staking_contract, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // Try and create a proposal, will fail as no longer active + let _res = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod, + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap_err(); +} + +#[test] +fn test_active_threshold_none() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + min_voting_period: None, + close_proposal_on_execution_failure: true, + max_voting_period, + only_members_execute: false, + allow_revoting: false, + voting_strategy, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = + instantiate_with_staking_active_threshold(&mut app, instantiate.clone(), None, None); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + let govmod_config: Config = query_proposal_config(&app, &govmod); + let dao = govmod_config.dao; + let voting_module: Addr = app + .wrap() + .query_wasm_smart(dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Stake some tokens so we can propose + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(2000), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), token_contract, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + // Try and create a proposal, will succeed as no threshold + let _res = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod, + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options.clone(), + proposer: None, + }, + &[], + ) + .unwrap(); + + // Now try with balance voting to test when IsActive is not implemented + // on the contract + let _threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + let _max_voting_period = cw_utils::Duration::Height(6); + + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + // Try and create a proposal, will succeed as IsActive is not implemented + let _res = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod, + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); +} + +/// Basic test for revoting on prop-multiple +#[test] +fn test_revoting() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: true, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }, + Some(vec![ + Cw20Coin { + address: "a-1".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "a-2".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + + let govmod = query_multiple_proposal_module(&app, &core_addr); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + let mc_options = MultipleChoiceOptions { options }; + + // Create a basic proposal with 2 options + app.execute_contract( + Addr::unchecked("a-1"), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // a-1 votes, vote_weights: [100_000_000, 0] + app.execute_contract( + Addr::unchecked("a-1"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // a-2 votes, vote_weights: [100_000_000, 100_000_000] + app.execute_contract( + Addr::unchecked("a-2"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 1 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Time passes.. + app.update_block(|b| b.height += 2); + + // Assert that both vote options have equal vote weights at some block + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + assert_eq!(proposal.proposal.status, Status::Open); + assert_eq!( + proposal.proposal.votes.vote_weights[0], + Uint128::new(100_000_000), + ); + assert_eq!( + proposal.proposal.votes.vote_weights[1], + Uint128::new(100_000_000), + ); + + // More time passes.. + app.update_block(|b| b.height += 3); + + // Last moment a-2 has a change of mind, + // votes shift to [200_000_000, 0] + app.execute_contract( + Addr::unchecked("a-2"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + // Assert that revote succeeded + let proposal: ProposalResponse = query_proposal(&app, &govmod, 1); + assert_eq!(proposal.proposal.status, Status::Passed); + assert_eq!( + proposal.proposal.votes.vote_weights[0], + Uint128::new(200_000_000), + ); + assert_eq!(proposal.proposal.votes.vote_weights[1], Uint128::new(0),); +} + +/// Tests that revoting is stored at a per-proposal level. +/// Proposals created while revoting is enabled should not +/// have it disabled if a config change turns if off. +#[test] +fn test_allow_revoting_config_changes() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: true, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }, + Some(vec![ + Cw20Coin { + address: "a-1".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "a-2".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + + let proposal_module = query_multiple_proposal_module(&app, &core_addr); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + let mc_options = MultipleChoiceOptions { options }; + + // Create a basic proposal with 2 options that allows revoting + app.execute_contract( + Addr::unchecked("a-1"), + proposal_module.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options.clone(), + proposer: None, + }, + &[], + ) + .unwrap(); + + // Disable revoting + app.execute_contract( + core_addr.clone(), + proposal_module.clone(), + &ExecuteMsg::UpdateConfig { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: false, + dao: core_addr.to_string(), + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + }, + &[], + ) + .unwrap(); + + // Assert that proposal_id: 1 still allows revoting + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, 1); + assert!(proposal.proposal.allow_revoting); + + app.execute_contract( + Addr::unchecked("a-1"), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + app.execute_contract( + Addr::unchecked("a-1"), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 1 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // New proposals should not allow revoting + app.execute_contract( + Addr::unchecked("a-2"), + proposal_module.clone(), + &ExecuteMsg::Propose { + title: "A very complex text proposal".to_string(), + description: "A very complex text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked("a-2"), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id: 2, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked("a-2"), + proposal_module, + &ExecuteMsg::Vote { + proposal_id: 2, + vote: MultipleChoiceVote { option_id: 1 }, + rationale: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::AlreadyVoted {})); +} + +/// Tests that we error if a revote casts the same vote as the +/// previous vote. +#[test] +fn test_revoting_same_vote_twice() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: true, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }, + Some(vec![ + Cw20Coin { + address: "a-1".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "a-2".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + + let proprosal_module = query_multiple_proposal_module(&app, &core_addr); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + let mc_options = MultipleChoiceOptions { options }; + + // Create a basic proposal with 2 options that allows revoting + app.execute_contract( + Addr::unchecked("a-1"), + proprosal_module.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // Cast a vote + app.execute_contract( + Addr::unchecked("a-1"), + proprosal_module.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Revote for the same option as currently voted + let err: ContractError = app + .execute_contract( + Addr::unchecked("a-1"), + proprosal_module, + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // Can't cast the same vote twice. + assert!(matches!(err, ContractError::AlreadyCast {})); +} + +/// Tests that revoting into a non-existing vote option +/// does not invalidate the initial vote +#[test] +fn test_invalid_revote_does_not_invalidate_initial_vote() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: true, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }, + Some(vec![ + Cw20Coin { + address: "a-1".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "a-2".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + + let proposal_module = query_multiple_proposal_module(&app, &core_addr); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + let mc_options = MultipleChoiceOptions { options }; + + // Create a basic proposal with 2 options + app.execute_contract( + Addr::unchecked("a-1"), + proposal_module.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // a-1 votes, vote_weights: [100_000_000, 0] + app.execute_contract( + Addr::unchecked("a-1"), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // a-2 votes, vote_weights: [100_000_000, 100_000_000] + app.execute_contract( + Addr::unchecked("a-2"), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 1 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + app.update_block(next_block); + + // Assert that both vote options have equal vote weights at some block + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Open); + assert_eq!( + proposal.proposal.votes.vote_weights[0], + Uint128::new(100_000_000), + ); + assert_eq!( + proposal.proposal.votes.vote_weights[1], + Uint128::new(100_000_000), + ); + + // Time passes.. + app.update_block(|b| b.height += 3); + + // Last moment a-2 has a change of mind and attempts + // to vote for a non-existing option + let err: ContractError = app + .execute_contract( + Addr::unchecked("a-2"), + proposal_module, + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 99 }, + rationale: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + // Assert that prior votes remained the same + assert_eq!( + proposal.proposal.votes.vote_weights[0], + Uint128::new(100_000_000), + ); + assert_eq!( + proposal.proposal.votes.vote_weights[1], + Uint128::new(100_000_000), + ); + assert!(matches!(err, ContractError::InvalidVote {})); +} + +#[test] +fn test_return_deposit_to_dao_on_proposal_failure() { + let (mut app, core_addr) = do_test_votes_cw20_balances( + vec![TestMultipleChoiceVote { + voter: "blue".to_string(), + position: MultipleChoiceVote { option_id: 2 }, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + Status::Open, + Some(Uint128::new(100)), + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ); + + let core_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let proposal_modules = core_state.proposal_modules; + + assert_eq!(proposal_modules.len(), 1); + let proposal_multiple = proposal_modules.into_iter().next().unwrap().address; + + // Make the proposal expire. It has now failed. + app.update_block(|block| block.height += 10); + + // Close the proposal, this should work as the proposal is now + // open and expired. + app.execute_contract( + Addr::unchecked("keze"), + proposal_multiple.clone(), + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap(); + + let (deposit_config, _) = query_deposit_config_and_pre_propose_module(&app, &proposal_multiple); + if let CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + .. + } = deposit_config.deposit_info.unwrap() + { + // // Deposit should now belong to the DAO. + let balance = query_balance_cw20(&app, token, core_addr.to_string()); + assert_eq!(balance, Uint128::new(1)); + } else { + panic!() + }; +} + +#[test] +fn test_close_failed_proposal() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + + let quorum = PercentageThreshold::Majority {}; + let voting_strategy = VotingStrategy::SingleChoice { quorum }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + max_voting_period, + voting_strategy, + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + + let core_addr = instantiate_with_staking_active_threshold(&mut app, instantiate, None, None); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + let govmod_config: Config = query_proposal_config(&app, &govmod); + let dao = govmod_config.dao; + let voting_module: Addr = app + .wrap() + .query_wasm_smart(dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Stake some tokens so we can propose + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(2000), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_contract.clone(), + &msg, + &[], + ) + .unwrap(); + app.update_block(next_block); + + let msg = cw20::Cw20ExecuteMsg::Burn { + amount: Uint128::new(2000), + }; + let binary_msg = to_binary(&msg).unwrap(); + + let options = vec![ + MultipleChoiceOption { + description: "Burn or burn".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: token_contract.to_string(), + msg: binary_msg, + funds: vec![], + } + .into()], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "Don't burn".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + + // Overburn tokens + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple burn tokens proposal".to_string(), + description: "Burning more tokens, than dao treasury have".to_string(), + choices: mc_options.clone(), + proposer: None, + }, + &[], + ) + .unwrap(); + + // Vote on proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Update block + let timestamp = Timestamp::from_seconds(300_000_000); + app.update_block(|block| block.time = timestamp); + + // Execute proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + let failed: ProposalResponse = query_proposal(&app, &govmod, 1); + + assert_eq!(failed.proposal.status, Status::ExecutionFailed); + // With disabled feature + // Disable feature first + { + let original: Config = query_proposal_config(&app, &govmod); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "Disable closing failed proposals".to_string(), + description: "We want to re-execute failed proposals".to_string(), + choices: MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + description: "Disable closing failed proposals".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: govmod.to_string(), + msg: to_binary(&ExecuteMsg::UpdateConfig { + voting_strategy: VotingStrategy::SingleChoice { quorum }, + max_voting_period: original.max_voting_period, + min_voting_period: original.min_voting_period, + only_members_execute: original.only_members_execute, + allow_revoting: false, + dao: original.dao.to_string(), + close_proposal_on_execution_failure: false, + }) + .unwrap(), + funds: vec![], + } + .into()], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "Don't disable".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ], + }, + proposer: None, + }, + &[], + ) + .unwrap(); + + // Vote on proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 2, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Execute proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 2 }, + &[], + ) + .unwrap(); + } + + // Overburn tokens (again), this time without reverting + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A simple burn tokens proposal".to_string(), + description: "Burning more tokens, than dao treasury have".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // Vote on proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 3, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Execute proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 3 }, + &[], + ) + .expect_err("Should be sub overflow"); + + // Status should still be passed + let updated: ProposalResponse = query_proposal(&app, &govmod, 3); + + // not reverted + assert_eq!(updated.proposal.status, Status::Passed); +} + +#[test] +fn test_no_double_refund_on_execute_fail_and_close() { + let mut app = App::default(); + let _proposal_module_id = app.store_code(proposal_multiple_contract()); + + let voting_strategy = VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + voting_strategy, + max_voting_period, + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + // Important to set to true here as we want to be sure + // that we don't get a second refund on close. Refunds on + // close only happen if this is true. + refund_policy: DepositRefundPolicy::Always, + }), + false, + ), + }; + + let core_addr = instantiate_with_staking_active_threshold( + &mut app, + instantiate, + Some(vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + // One token for sending to the DAO treasury, one token + // for staking, one token for paying the proposal deposit. + amount: Uint128::new(3), + }]), + None, + ); + let govmod = query_multiple_proposal_module(&app, &core_addr); + + let proposal_config: Config = query_proposal_config(&app, &govmod); + let dao = proposal_config.dao; + let voting_module: Addr = app + .wrap() + .query_wasm_smart(dao, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap(); + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Stake a token so we can propose. + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(1), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_contract.clone(), + &msg, + &[], + ) + .unwrap(); + app.update_block(next_block); + + // Send some tokens to the proposal module so it has the ability + // to double refund if the code is buggy. + let msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: govmod.to_string(), + amount: Uint128::new(1), + }; + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_contract.clone(), + &msg, + &[], + ) + .unwrap(); + + let msg = cw20::Cw20ExecuteMsg::Burn { + amount: Uint128::new(2000), + }; + let binary_msg = to_binary(&msg).unwrap(); + + // Increase allowance to pay the proposal deposit. + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_contract.clone(), + &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + spender: govmod.to_string(), + amount: Uint128::new(1), + expires: None, + }, + &[], + ) + .unwrap(); + + let choices = MultipleChoiceOptions { + options: vec![ + MultipleChoiceOption { + description: "Burning more tokens, than dao treasury have".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: token_contract.to_string(), + msg: binary_msg, + funds: vec![], + } + .into()], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "hi there".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ], + }; + + make_proposal( + &mut app, + &govmod, + Addr::unchecked(CREATOR_ADDR).as_str(), + choices, + ); + + // Vote on proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Update block + let timestamp = Timestamp::from_seconds(300_000_000); + app.update_block(|block| block.time = timestamp); + + // Execute proposal + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + + let failed: ProposalResponse = query_proposal(&app, &govmod, 1); + + assert_eq!(failed.proposal.status, Status::ExecutionFailed); + + // Check that our deposit has been refunded. + let balance = query_balance_cw20(&app, token_contract.to_string(), CREATOR_ADDR); + assert_eq!(balance, Uint128::new(1)); + + // Close the proposal - this should fail as it was executed. + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod, + &ExecuteMsg::Close { proposal_id: 1 }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::WrongCloseStatus {})); + + // Check that our deposit was not refunded a second time on close. + let balance = query_balance_cw20(&app, token_contract.to_string(), CREATOR_ADDR); + assert_eq!(balance, Uint128::new(1)); +} + +// Casting votes is only allowed within the proposal expiration timeframe +#[test] +pub fn test_not_allow_voting_on_expired_proposal() { + let mut app = App::default(); + let _govmod_id = app.store_code(proposal_multiple_contract()); + let instantiate = InstantiateMsg { + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: false, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + min_voting_period: None, + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }; + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "a-1".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "a-2".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + let govmod = query_multiple_proposal_module(&app, &core_addr); + let proposal_module = query_multiple_proposal_module(&app, &core_addr); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + let mc_options = MultipleChoiceOptions { options }; + + // Create a basic proposal + app.execute_contract( + Addr::unchecked("a-1"), + proposal_module.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // assert proposal is open + let proposal = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Open); + + // expire the proposal and attempt to vote + app.update_block(|block| block.height += 6); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod, + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // assert the vote got rejected and did not count towards the votes + let proposal = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Rejected); + assert_eq!(proposal.proposal.votes.vote_weights[0], Uint128::zero()); + assert!(matches!(err, ContractError::Expired { id: _proposal_id })); +} + +// tests the next proposal id query. +#[test] +fn test_next_proposal_id() { + let mut app = App::default(); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: true, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }, + Some(vec![ + Cw20Coin { + address: "a-1".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "a-2".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + + let proposal_module = query_multiple_proposal_module(&app, &core_addr); + + let next_proposal_id: u64 = app + .wrap() + .query_wasm_smart(&proposal_module, &QueryMsg::NextProposalId {}) + .unwrap(); + assert_eq!(next_proposal_id, 1); + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + let mc_options = MultipleChoiceOptions { options }; + + // Create a basic proposal with 2 options + app.execute_contract( + Addr::unchecked("a-1"), + proposal_module.clone(), + &ExecuteMsg::Propose { + title: "A simple text proposal".to_string(), + description: "A simple text proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + let next_proposal_id: u64 = app + .wrap() + .query_wasm_smart(&proposal_module, &QueryMsg::NextProposalId {}) + .unwrap(); + assert_eq!(next_proposal_id, 2); +} + +#[test] +fn test_vote_with_rationale() { + let mut app = App::default(); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: false, + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "elub".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title 1".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title 2".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A proposal".to_string(), + description: "A simple proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: Some("I think this is a good idea".to_string()), + }, + &[], + ) + .unwrap(); + + // Query rationale + let vote_resp: VoteResponse = app + .wrap() + .query_wasm_smart( + govmod, + &QueryMsg::GetVote { + proposal_id: 1, + voter: "blue".to_string(), + }, + ) + .unwrap(); + + let vote = vote_resp.vote.unwrap(); + assert_eq!(vote.vote.option_id, 0); + assert_eq!( + vote.rationale, + Some("I think this is a good idea".to_string()) + ); +} + +#[test] +fn test_revote_with_rationale() { + let mut app = App::default(); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: true, // Enable revoting + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "elub".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title 1".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title 2".to_string(), + }, + ]; + + let mc_options = MultipleChoiceOptions { options }; + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A proposal".to_string(), + description: "A simple proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: Some("I think this is a good idea".to_string()), + }, + &[], + ) + .unwrap(); + + // Query rationale + let vote_resp: VoteResponse = app + .wrap() + .query_wasm_smart( + govmod.clone(), + &QueryMsg::GetVote { + proposal_id: 1, + voter: "blue".to_string(), + }, + ) + .unwrap(); + + let vote = vote_resp.vote.unwrap(); + assert_eq!(vote.vote.option_id, 0); + assert_eq!( + vote.rationale, + Some("I think this is a good idea".to_string()) + ); + + // Revote with rationale + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 1 }, + rationale: Some("Nah".to_string()), + }, + &[], + ) + .unwrap(); + + // Query rationale and ensure it changed + let vote_resp: VoteResponse = app + .wrap() + .query_wasm_smart( + govmod.clone(), + &QueryMsg::GetVote { + proposal_id: 1, + voter: "blue".to_string(), + }, + ) + .unwrap(); + + let vote = vote_resp.vote.unwrap(); + assert_eq!(vote.vote.option_id, 1); + assert_eq!(vote.rationale, Some("Nah".to_string())); + + // Revote without rationale + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 2 }, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Query rationale and ensure it changed + let vote_resp: VoteResponse = app + .wrap() + .query_wasm_smart( + govmod, + &QueryMsg::GetVote { + proposal_id: 1, + voter: "blue".to_string(), + }, + ) + .unwrap(); + + let vote = vote_resp.vote.unwrap(); + assert_eq!(vote.vote.option_id, 2); + assert_eq!(vote.rationale, None); +} + +#[test] +fn test_update_rationale() { + let mut app = App::default(); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + min_voting_period: None, + max_voting_period: Duration::Height(6), + only_members_execute: false, + allow_revoting: true, // Enable revoting + voting_strategy: VotingStrategy::SingleChoice { + quorum: PercentageThreshold::Majority {}, + }, + close_proposal_on_execution_failure: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + }, + Some(vec![ + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "elub".to_string(), + amount: Uint128::new(100_000_000), + }, + ]), + ); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let governance_modules = gov_state.proposal_modules; + + assert_eq!(governance_modules.len(), 1); + let govmod = governance_modules.into_iter().next().unwrap().address; + + let options = vec![ + MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title 1".to_string(), + }, + MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title 2".to_string(), + }, + ]; + + // Propose something + let mc_options = MultipleChoiceOptions { options }; + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod.clone(), + &ExecuteMsg::Propose { + title: "A proposal".to_string(), + description: "A simple proposal".to_string(), + choices: mc_options, + proposer: None, + }, + &[], + ) + .unwrap(); + + // Vote with rationale + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: MultipleChoiceVote { option_id: 0 }, + rationale: Some("I think this is a good idea".to_string()), + }, + &[], + ) + .unwrap(); + + // Query rationale + let vote_resp: VoteResponse = app + .wrap() + .query_wasm_smart( + govmod.clone(), + &QueryMsg::GetVote { + proposal_id: 1, + voter: "blue".to_string(), + }, + ) + .unwrap(); + + let vote = vote_resp.vote.unwrap(); + assert_eq!(vote.vote.option_id, 0); + assert_eq!( + vote.rationale, + Some("I think this is a good idea".to_string()) + ); + + // Update rationale + app.execute_contract( + Addr::unchecked("blue"), + govmod.clone(), + &ExecuteMsg::UpdateRationale { + proposal_id: 1, + rationale: Some("This may be a good idea, but I'm not sure. YOLO".to_string()), + }, + &[], + ) + .unwrap(); + + // Query rationale + let vote_resp: VoteResponse = app + .wrap() + .query_wasm_smart( + govmod, + &QueryMsg::GetVote { + proposal_id: 1, + voter: "blue".to_string(), + }, + ) + .unwrap(); + + let vote = vote_resp.vote.unwrap(); + assert_eq!(vote.vote.option_id, 0); + assert_eq!( + vote.rationale, + Some("This may be a good idea, but I'm not sure. YOLO".to_string()) + ); +} diff --git a/contracts/proposal/dao-proposal-single/.cargo/config b/contracts/proposal/dao-proposal-single/.cargo/config new file mode 100644 index 000000000..ab407a024 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/proposal/dao-proposal-single/Cargo.toml b/contracts/proposal/dao-proposal-single/Cargo.toml new file mode 100644 index 000000000..fdf6e842d --- /dev/null +++ b/contracts/proposal/dao-proposal-single/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "dao-proposal-single" +authors = ["ekez "] +description = "A DAO DAO proposal module for single choice (yes / no) voting." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true, features = ["ibc3"] } +cosmwasm-storage = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw3 = { workspace = true } +thiserror = { workspace = true } + +dao-dao-macros = { workspace = true } +dao-pre-propose-base = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +cw-hooks = { workspace = true } +dao-proposal-hooks = { workspace = true } +dao-vote-hooks = { workspace = true } + +cw-utils-v1 = { workspace = true} +voting-v1 = { workspace = true } +cw-proposal-single-v1 = { workspace = true, features = ["library"] } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } +cw-multi-test = { workspace = true } +dao-dao-core = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting-cw20-balance = { workspace = true } +dao-voting-cw20-staked = { workspace = true } +dao-voting-native-staked = { workspace = true } +dao-voting-cw721-staked = { workspace = true } +dao-pre-propose-single = { workspace = true } +cw-denom = { workspace = true } +dao-testing = { workspace = true } +cw20-stake = { workspace = true } +cw20-base = { workspace = true } +cw721-base = { workspace = true } +cw4 = { workspace = true } +cw4-group = { workspace = true } +cw-core-v1 = { workspace = true, features = ["library"] } diff --git a/contracts/proposal/dao-proposal-single/README.md b/contracts/proposal/dao-proposal-single/README.md new file mode 100644 index 000000000..78c547f1f --- /dev/null +++ b/contracts/proposal/dao-proposal-single/README.md @@ -0,0 +1,58 @@ +# dao-proposal-single + +A proposal module for a DAO DAO DAO which supports simple "yes", "no", +"abstain" voting. Proposals may have associated messages which will be +executed by the core module upon the proposal being passed and +executed. + +Votes can be cast for as long as the proposal is not expired. In cases +where the proposal is no longer being evaluated (e.g. met the quorum and +been rejected), this allows voters to reflect their opinion even though +it has no effect on the final proposal's status. + +For more information about how these modules fit together see +[this](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design) +wiki page. + +For information about how this module counts votes and handles passing +thresholds see +[this](https://github.com/DA0-DA0/dao-contracts/wiki/A-brief-overview-of-DAO-DAO-voting#proposal-status) +wiki page. + +## Undesired behavior + +The undesired behavior of this contract is tested under `testing/adversarial_tests.rs`. + +In general, it should cover: +- Executing unpassed proposals +- Executing proposals more than once +- Social engineering proposals for financial benefit +- Convincing proposal modules to spend someone else's allowance + +## Proposal deposits + +Proposal deposits for this module are handled by the +[`dao-pre-propose-single`](../../pre-propose/dao-pre-propose-single) +contract. + +## Hooks + +This module supports hooks for voting and proposal status changes. One +may register a contract to receive these hooks with the `AddVoteHook` +and `AddProposalHook` methods. Upon registration the contract will +receive messages whenever a vote is cast and a proposal's status +changes (for example, when the proposal passes). + +The format for these hook messages can be located in the +`proposal-hooks` and `vote-hooks` packages located in +`packages/proposal-hooks` and `packages/vote-hooks` respectively. + +To stop an invalid hook receiver from locking the proposal module +receivers will be removed from the hook list if they error when +handling a hook. + +## Revoting + +The proposals may be configured to allow revoting. +In such cases, users are able to change their vote as long as the proposal is still open. +Revoting for the currently cast option will return an error. diff --git a/contracts/proposal/dao-proposal-single/examples/schema.rs b/contracts/proposal/dao-proposal-single/examples/schema.rs new file mode 100644 index 000000000..9a8dfd2c1 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_proposal_single::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json b/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json new file mode 100644 index 000000000..87f802625 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json @@ -0,0 +1,5921 @@ +{ + "contract_name": "dao-proposal-single", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "max_voting_period", + "only_members_execute", + "pre_propose_info", + "threshold" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "max_voting_period": { + "description": "The default maximum amount of time a proposal may be voted on before expiring.", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal.", + "type": "boolean" + }, + "pre_propose_info": { + "description": "Information about what addresses may create proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeInfo" + } + ] + }, + "threshold": { + "description": "The threshold a proposal must reach to complete.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "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 + } + ] + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$ref": "#/definitions/Uint128" + } + }, + "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": "Creates a proposal in the module.", + "type": "object", + "required": [ + "propose" + ], + "properties": { + "propose": { + "$ref": "#/definitions/SingleChoiceProposeMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Votes on a proposal. Voting power is determined by the DAO's voting power module.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to vote on.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "rationale": { + "description": "An optional rationale for why this vote was cast. This can be updated, set, or removed later by the address casting the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "The senders position on the proposal.", + "allOf": [ + { + "$ref": "#/definitions/Vote" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the sender's rationale for their vote on the specified proposal. Errors if no vote vote has been cast.", + "type": "object", + "required": [ + "update_rationale" + ], + "properties": { + "update_rationale": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "rationale": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Causes the messages associated with a passed proposal to be executed by the DAO.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to execute.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Closes a proposal that has failed (either not passed or timed out). If applicable this will cause the proposal deposit associated wth said proposal to be returned.", + "type": "object", + "required": [ + "close" + ], + "properties": { + "close": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "description": "The ID of the proposal to close.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the governance module's config.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "dao", + "max_voting_period", + "only_members_execute", + "threshold" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "dao": { + "description": "The address if tge DAO that this governance module is associated with.", + "type": "string" + }, + "max_voting_period": { + "description": "The default maximum amount of time a proposal may be voted on before expiring. This will only apply to proposals created after the config update.", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal. Applies to all outstanding and future proposals.", + "type": "boolean" + }, + "threshold": { + "description": "The new proposal passing threshold. This will only apply to proposals created after the config update.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update's the proposal creation policy used for this module. Only the DAO may call this method.", + "type": "object", + "required": [ + "update_pre_propose_info" + ], + "properties": { + "update_pre_propose_info": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/PreProposeInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds an address as a consumer of proposal hooks. Consumers of proposal hooks have hook messages executed on them whenever the status of a proposal changes or a proposal is created. If a consumer contract errors when handling a hook message it will be removed from the list of consumers.", + "type": "object", + "required": [ + "add_proposal_hook" + ], + "properties": { + "add_proposal_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a consumer of proposal hooks.", + "type": "object", + "required": [ + "remove_proposal_hook" + ], + "properties": { + "remove_proposal_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds an address as a consumer of vote hooks. Consumers of vote hooks have hook messages executed on them whenever the a vote is cast. If a consumer contract errors when handling a hook message it will be removed from the list of consumers.", + "type": "object", + "required": [ + "add_vote_hook" + ], + "properties": { + "add_vote_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removed a consumer of vote hooks.", + "type": "object", + "required": [ + "remove_vote_hook" + ], + "properties": { + "remove_vote_hook": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "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 + } + ] + }, + "Empty": { + "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" + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "SingleChoiceProposeMsg": { + "description": "The contents of a message to create a proposal in the single choice proposal module.\n\nWe break this type out of `ExecuteMsg` because we want pre-propose modules that interact with this contract to be able to get type checking on their propose messages.\n\nWe move this type to this package so that pre-propose modules can import it without importing dao-proposal-single with the library feature which (as it is not additive) cause the execute exports to not be included in wasm builds.", + "type": "object", + "required": [ + "description", + "msgs", + "title" + ], + "properties": { + "description": { + "description": "A description of the proposal.", + "type": "string" + }, + "msgs": { + "description": "The messages that should be executed in response to this proposal passing.", + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "proposer": { + "description": "The address creating the proposal. If no pre-propose module is attached to this module this must always be None as the proposer is the sender of the propose message. If a pre-propose module is attached, this must be Some and will set the proposer of the proposal it creates.", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "The title of the proposal.", + "type": "string" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$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" + }, + "Vote": { + "oneOf": [ + { + "description": "Marks support for the proposal.", + "type": "string", + "enum": [ + "yes" + ] + }, + { + "description": "Marks opposition to the proposal.", + "type": "string", + "enum": [ + "no" + ] + }, + { + "description": "Marks participation but does not count towards the ratio of support / opposed.", + "type": "string", + "enum": [ + "abstain" + ] + } + ] + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Gets the proposal module's config.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets information about a proposal.", + "type": "object", + "required": [ + "proposal" + ], + "properties": { + "proposal": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all the proposals that have been cast in this module.", + "type": "object", + "required": [ + "list_proposals" + ], + "properties": { + "list_proposals": { + "type": "object", + "properties": { + "limit": { + "description": "The maximum number of proposals to return as part of this query. If no limit is set a max of 30 proposals will be returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "start_after": { + "description": "The proposal ID to start listing proposals after. For example, if this is set to 2 proposals with IDs 3 and higher will be returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the proposals that have been cast in this module in decending order of proposal ID.", + "type": "object", + "required": [ + "reverse_proposals" + ], + "properties": { + "reverse_proposals": { + "type": "object", + "properties": { + "limit": { + "description": "The maximum number of proposals to return as part of this query. If no limit is set a max of 30 proposals will be returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "start_before": { + "description": "The proposal ID to start listing proposals before. For example, if this is set to 6 proposals with IDs 5 and lower will be returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns a voters position on a propsal.", + "type": "object", + "required": [ + "get_vote" + ], + "properties": { + "get_vote": { + "type": "object", + "required": [ + "proposal_id", + "voter" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "voter": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the votes that have been cast on a proposal.", + "type": "object", + "required": [ + "list_votes" + ], + "properties": { + "list_votes": { + "type": "object", + "required": [ + "proposal_id" + ], + "properties": { + "limit": { + "description": "The maximum number of votes to return in response to this query. If no limit is specified a max of 30 are returned.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "proposal_id": { + "description": "The proposal to list the votes of.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "start_after": { + "description": "The voter to start listing votes after. Ordering is done alphabetically.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the number of proposals that have been created in this module.", + "type": "object", + "required": [ + "proposal_count" + ], + "properties": { + "proposal_count": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Gets the current proposal creation policy for this module.", + "type": "object", + "required": [ + "proposal_creation_policy" + ], + "properties": { + "proposal_creation_policy": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the consumers of proposal hooks for this module.", + "type": "object", + "required": [ + "proposal_hooks" + ], + "properties": { + "proposal_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Lists all of the consumers of vote hooks for this module.", + "type": "object", + "required": [ + "vote_hooks" + ], + "properties": { + "vote_hooks": { + "type": "object", + "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 + }, + { + "description": "Returns the proposal ID that will be assigned to the next proposal created.", + "type": "object", + "required": [ + "next_proposal_id" + ], + "properties": { + "next_proposal_id": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "from_v1" + ], + "properties": { + "from_v1": { + "type": "object", + "required": [ + "close_proposal_on_execution_failure", + "pre_propose_info" + ], + "properties": { + "close_proposal_on_execution_failure": { + "description": "This field was not present in DAO DAO v1. To migrate, a value must be specified.\n\nIf set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "pre_propose_info": { + "description": "This field was not present in DAO DAO v1. To migrate, a value must be specified.\n\nThis contains information about how a pre-propose module may be configured. If set to \"AnyoneMayPropose\", there will be no pre-propose module and consequently, no deposit or membership checks when submitting a proposal. The \"ModuleMayPropose\" option allows for instantiating a prepropose module which will handle deposit verification and return logic.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeInfo" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "from_compatible" + ], + "properties": { + "from_compatible": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "ModuleInstantiateInfo": { + "description": "Information needed to instantiate a module.", + "type": "object", + "required": [ + "code_id", + "label", + "msg" + ], + "properties": { + "admin": { + "description": "CosmWasm level admin of the instantiated contract. See: ", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "code_id": { + "description": "Code ID of the contract to be instantiated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "label": { + "description": "Label for the instantiated contract.", + "type": "string" + }, + "msg": { + "description": "Instantiate message to be used to create the contract.", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + }, + "additionalProperties": false + }, + "PreProposeInfo": { + "oneOf": [ + { + "description": "Anyone may create a proposal free of charge.", + "type": "object", + "required": [ + "anyone_may_propose" + ], + "properties": { + "anyone_may_propose": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The module specified in INFO has exclusive rights to proposal creation.", + "type": "object", + "required": [ + "module_may_propose" + ], + "properties": { + "module_may_propose": { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ModuleInstantiateInfo" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "The governance module's configuration.", + "type": "object", + "required": [ + "allow_revoting", + "close_proposal_on_execution_failure", + "dao", + "max_voting_period", + "only_members_execute", + "threshold" + ], + "properties": { + "allow_revoting": { + "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.", + "type": "boolean" + }, + "close_proposal_on_execution_failure": { + "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", + "type": "boolean" + }, + "dao": { + "description": "The address of the DAO that this governance module is associated with.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "max_voting_period": { + "description": "The default maximum amount of time a proposal may be voted on before expiring.", + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "only_members_execute": { + "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal.", + "type": "boolean" + }, + "threshold": { + "description": "The threshold a proposal must reach to complete.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + } + }, + "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" + }, + "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 + } + ] + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$ref": "#/definitions/Uint128" + } + }, + "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" + } + } + }, + "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" + }, + "get_vote": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoteResponse", + "description": "Information about a vote.", + "type": "object", + "properties": { + "vote": { + "description": "None if no such vote, Some otherwise.", + "anyOf": [ + { + "$ref": "#/definitions/VoteInfo" + }, + { + "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" + }, + "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" + }, + "Vote": { + "oneOf": [ + { + "description": "Marks support for the proposal.", + "type": "string", + "enum": [ + "yes" + ] + }, + { + "description": "Marks opposition to the proposal.", + "type": "string", + "enum": [ + "no" + ] + }, + { + "description": "Marks participation but does not count towards the ratio of support / opposed.", + "type": "string", + "enum": [ + "abstain" + ] + } + ] + }, + "VoteInfo": { + "description": "Information about a vote that was cast.", + "type": "object", + "required": [ + "power", + "vote", + "voter" + ], + "properties": { + "power": { + "description": "The voting power behind the vote.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "rationale": { + "description": "Address-specified rationale for the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "Position on the vote.", + "allOf": [ + { + "$ref": "#/definitions/Vote" + } + ] + }, + "voter": { + "description": "The address that voted.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "additionalProperties": false, + "definitions": { + "ContractVersion": { + "type": "object", + "required": [ + "contract", + "version" + ], + "properties": { + "contract": { + "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", + "type": "string" + }, + "version": { + "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_proposals": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalListResponse", + "description": "A list of proposals returned by `ListProposals` and `ReverseProposals`.", + "type": "object", + "required": [ + "proposals" + ], + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/definitions/ProposalResponse" + } + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "ProposalResponse": { + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "description": "The ID of the proposal being returned.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/SingleChoiceProposal" + } + }, + "additionalProperties": false + }, + "SingleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "description", + "expiration", + "msgs", + "proposer", + "start_height", + "status", + "threshold", + "title", + "total_power", + "votes" + ], + "properties": { + "allow_revoting": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "msgs": { + "description": "The messages that will be executed should this proposal pass.", + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "$ref": "#/definitions/Status" + }, + "threshold": { + "description": "The threshold at which this proposal will pass.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total amount of voting power at the time of this proposal's creation.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "$ref": "#/definitions/Votes" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "Votes": { + "type": "object", + "required": [ + "abstain", + "no", + "yes" + ], + "properties": { + "abstain": { + "$ref": "#/definitions/Uint128" + }, + "no": { + "$ref": "#/definitions/Uint128" + }, + "yes": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "list_votes": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VoteListResponse", + "description": "Information about the votes for a proposal.", + "type": "object", + "required": [ + "votes" + ], + "properties": { + "votes": { + "type": "array", + "items": { + "$ref": "#/definitions/VoteInfo" + } + } + }, + "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" + }, + "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" + }, + "Vote": { + "oneOf": [ + { + "description": "Marks support for the proposal.", + "type": "string", + "enum": [ + "yes" + ] + }, + { + "description": "Marks opposition to the proposal.", + "type": "string", + "enum": [ + "no" + ] + }, + { + "description": "Marks participation but does not count towards the ratio of support / opposed.", + "type": "string", + "enum": [ + "abstain" + ] + } + ] + }, + "VoteInfo": { + "description": "Information about a vote that was cast.", + "type": "object", + "required": [ + "power", + "vote", + "voter" + ], + "properties": { + "power": { + "description": "The voting power behind the vote.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "rationale": { + "description": "Address-specified rationale for the vote.", + "type": [ + "string", + "null" + ] + }, + "vote": { + "description": "Position on the vote.", + "allOf": [ + { + "$ref": "#/definitions/Vote" + } + ] + }, + "voter": { + "description": "The address that voted.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "next_proposal_id": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalResponse", + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "description": "The ID of the proposal being returned.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/SingleChoiceProposal" + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "SingleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "description", + "expiration", + "msgs", + "proposer", + "start_height", + "status", + "threshold", + "title", + "total_power", + "votes" + ], + "properties": { + "allow_revoting": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "msgs": { + "description": "The messages that will be executed should this proposal pass.", + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "$ref": "#/definitions/Status" + }, + "threshold": { + "description": "The threshold at which this proposal will pass.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total amount of voting power at the time of this proposal's creation.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "$ref": "#/definitions/Votes" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "Votes": { + "type": "object", + "required": [ + "abstain", + "no", + "yes" + ], + "properties": { + "abstain": { + "$ref": "#/definitions/Uint128" + }, + "no": { + "$ref": "#/definitions/Uint128" + }, + "yes": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "proposal_count": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal_creation_policy": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalCreationPolicy", + "oneOf": [ + { + "description": "Anyone may create a proposal, free of charge.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only ADDR may create proposals. It is expected that ADDR is a pre-propose module, though we only require that it is a valid address.", + "type": "object", + "required": [ + "module" + ], + "properties": { + "module": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + }, + "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" + } + } + }, + "proposal_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "reverse_proposals": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ProposalListResponse", + "description": "A list of proposals returned by `ListProposals` and `ReverseProposals`.", + "type": "object", + "required": [ + "proposals" + ], + "properties": { + "proposals": { + "type": "array", + "items": { + "$ref": "#/definitions/ProposalResponse" + } + } + }, + "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" + }, + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "description": "A Stargate message encoded the same way as a protobuf [Any](https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/any.proto). This is the same structure as messages in `TxBody` from [ADR-020](https://github.com/cosmos/cosmos-sdk/blob/master/docs/architecture/adr-020-protobuf-transaction-encoding.md)", + "type": "object", + "required": [ + "stargate" + ], + "properties": { + "stargate": { + "type": "object", + "required": [ + "type_url", + "value" + ], + "properties": { + "type_url": { + "type": "string" + }, + "value": { + "$ref": "#/definitions/Binary" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ibc" + ], + "properties": { + "ibc": { + "$ref": "#/definitions/IbcMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "gov" + ], + "properties": { + "gov": { + "$ref": "#/definitions/GovMsg" + } + }, + "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" + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "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 + } + ] + }, + "GovMsg": { + "description": "This message type allows the contract interact with the [x/gov] module in order to cast votes.\n\n[x/gov]: https://github.com/cosmos/cosmos-sdk/tree/v0.45.12/x/gov\n\n## Examples\n\nCast a simple vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); use cosmwasm_std::{GovMsg, VoteOption};\n\n#[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::Vote { proposal_id: 4, vote: VoteOption::Yes, })) } ```\n\nCast a weighted vote:\n\n``` # use cosmwasm_std::{ # HexBinary, # Storage, Api, Querier, DepsMut, Deps, entry_point, Env, StdError, MessageInfo, # Response, QueryResponse, # }; # type ExecuteMsg = (); # #[cfg(feature = \"cosmwasm_1_2\")] use cosmwasm_std::{Decimal, GovMsg, VoteOption, WeightedVoteOption};\n\n# #[cfg(feature = \"cosmwasm_1_2\")] #[entry_point] pub fn execute( deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg, ) -> Result { // ... Ok(Response::new().add_message(GovMsg::VoteWeighted { proposal_id: 4, options: vec![ WeightedVoteOption { option: VoteOption::Yes, weight: Decimal::percent(65), }, WeightedVoteOption { option: VoteOption::Abstain, weight: Decimal::percent(35), }, ], })) } ```", + "oneOf": [ + { + "description": "This maps directly to [MsgVote](https://github.com/cosmos/cosmos-sdk/blob/v0.42.5/proto/cosmos/gov/v1beta1/tx.proto#L46-L56) in the Cosmos SDK with voter set to the contract address.", + "type": "object", + "required": [ + "vote" + ], + "properties": { + "vote": { + "type": "object", + "required": [ + "proposal_id", + "vote" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote option.\n\nThis should be called \"option\" for consistency with Cosmos SDK. Sorry for that. See .", + "allOf": [ + { + "$ref": "#/definitions/VoteOption" + } + ] + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcMsg": { + "description": "These are messages in the IBC lifecycle. Only usable by IBC-enabled contracts (contracts that directly speak the IBC protocol via 6 entry points)", + "oneOf": [ + { + "description": "Sends bank tokens owned by the contract to the given address on another chain. The channel must already be established between the ibctransfer module on this chain and a matching module on the remote chain. We cannot select the port_id, this is whatever the local chain has bound the ibctransfer module to.", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "channel_id", + "timeout", + "to_address" + ], + "properties": { + "amount": { + "description": "packet data only supports one coin https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "channel_id": { + "description": "exisiting channel to send the tokens over", + "type": "string" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + }, + "to_address": { + "description": "address on the remote chain to receive these tokens", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sends an IBC packet with given data over the existing channel. Data should be encoded in a format defined by the channel version, and the module on the other side should know how to parse this.", + "type": "object", + "required": [ + "send_packet" + ], + "properties": { + "send_packet": { + "type": "object", + "required": [ + "channel_id", + "data", + "timeout" + ], + "properties": { + "channel_id": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/Binary" + }, + "timeout": { + "description": "when packet times out, measured on remote chain", + "allOf": [ + { + "$ref": "#/definitions/IbcTimeout" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will close an existing channel that is owned by this contract. Port is auto-assigned to the contract's IBC port", + "type": "object", + "required": [ + "close_channel" + ], + "properties": { + "close_channel": { + "type": "object", + "required": [ + "channel_id" + ], + "properties": { + "channel_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "IbcTimeout": { + "description": "In IBC each package must set at least one type of timeout: the timestamp or the block height. Using this rather complex enum instead of two timeout fields we ensure that at least one timeout is set.", + "type": "object", + "properties": { + "block": { + "anyOf": [ + { + "$ref": "#/definitions/IbcTimeoutBlock" + }, + { + "type": "null" + } + ] + }, + "timestamp": { + "anyOf": [ + { + "$ref": "#/definitions/Timestamp" + }, + { + "type": "null" + } + ] + } + } + }, + "IbcTimeoutBlock": { + "description": "IBCTimeoutHeight Height is a monotonically increasing data type that can be compared against another Height for the purposes of updating and freezing clients. Ordering is (revision_number, timeout_height)", + "type": "object", + "required": [ + "height", + "revision" + ], + "properties": { + "height": { + "description": "block height after which the packet times out. the height within the given revision", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "revision": { + "description": "the version that the client is currently on (eg. after reseting the chain this could increment 1 as height drops to 0)", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, + "PercentageThreshold": { + "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `vote_weights >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.", + "oneOf": [ + { + "description": "The majority of voters must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "majority" + ], + "properties": { + "majority": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.", + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "ProposalResponse": { + "description": "Information about a proposal returned by proposal queries.", + "type": "object", + "required": [ + "id", + "proposal" + ], + "properties": { + "id": { + "description": "The ID of the proposal being returned.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal": { + "$ref": "#/definitions/SingleChoiceProposal" + } + }, + "additionalProperties": false + }, + "SingleChoiceProposal": { + "type": "object", + "required": [ + "allow_revoting", + "description", + "expiration", + "msgs", + "proposer", + "start_height", + "status", + "threshold", + "title", + "total_power", + "votes" + ], + "properties": { + "allow_revoting": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "expiration": { + "description": "The the time at which this proposal will expire and close for additional votes.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "min_voting_period": { + "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "msgs": { + "description": "The messages that will be executed should this proposal pass.", + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + }, + "proposer": { + "description": "The address that created this proposal.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "start_height": { + "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "$ref": "#/definitions/Status" + }, + "threshold": { + "description": "The threshold at which this proposal will pass.", + "allOf": [ + { + "$ref": "#/definitions/Threshold" + } + ] + }, + "title": { + "type": "string" + }, + "total_power": { + "description": "The total amount of voting power at the time of this proposal's creation.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "votes": { + "$ref": "#/definitions/Votes" + } + }, + "additionalProperties": false + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Status": { + "oneOf": [ + { + "description": "The proposal is open for voting.", + "type": "string", + "enum": [ + "open" + ] + }, + { + "description": "The proposal has been rejected.", + "type": "string", + "enum": [ + "rejected" + ] + }, + { + "description": "The proposal has been passed but has not been executed.", + "type": "string", + "enum": [ + "passed" + ] + }, + { + "description": "The proposal has been passed and executed.", + "type": "string", + "enum": [ + "executed" + ] + }, + { + "description": "The proposal has failed or expired and has been closed. A proposal deposit refund has been issued if applicable.", + "type": "string", + "enum": [ + "closed" + ] + }, + { + "description": "The proposal's execution failed.", + "type": "string", + "enum": [ + "execution_failed" + ] + } + ] + }, + "Threshold": { + "description": "The ways a proposal may reach its passing / failing threshold.", + "oneOf": [ + { + "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass. See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.", + "type": "object", + "required": [ + "absolute_percentage" + ], + "properties": { + "absolute_percentage": { + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.", + "type": "object", + "required": [ + "threshold_quorum" + ], + "properties": { + "threshold_quorum": { + "type": "object", + "required": [ + "quorum", + "threshold" + ], + "properties": { + "quorum": { + "$ref": "#/definitions/PercentageThreshold" + }, + "threshold": { + "$ref": "#/definitions/PercentageThreshold" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "An absolute number of votes needed for something to cross the threshold. Useful for multisig style voting.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "threshold" + ], + "properties": { + "threshold": { + "$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" + }, + "VoteOption": { + "type": "string", + "enum": [ + "yes", + "no", + "abstain", + "no_with_veto" + ] + }, + "Votes": { + "type": "object", + "required": [ + "abstain", + "no", + "yes" + ], + "properties": { + "abstain": { + "$ref": "#/definitions/Uint128" + }, + "no": { + "$ref": "#/definitions/Uint128" + }, + "yes": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThe contract address is non-predictable. But it is guaranteed that when emitting the same Instantiate message multiple times, multiple instances on different addresses will be generated. See also Instantiate2.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.29.2/proto/cosmwasm/wasm/v1/tx.proto#L53-L71). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } + }, + "vote_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/proposal/dao-proposal-single/src/contract.rs b/contracts/proposal/dao-proposal-single/src/contract.rs new file mode 100644 index 000000000..5f30030f5 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/contract.rs @@ -0,0 +1,1014 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Order, Reply, + Response, StdResult, Storage, SubMsg, WasmMsg, +}; +use cw2::{get_contract_version, set_contract_version, ContractVersion}; +use cw_hooks::Hooks; +use cw_proposal_single_v1 as v1; +use cw_storage_plus::Bound; +use cw_utils::{parse_reply_instantiate_data, Duration}; +use dao_interface::voting::IsActiveResponse; +use dao_proposal_hooks::{new_proposal_hooks, proposal_status_changed_hooks}; +use dao_vote_hooks::new_vote_hooks; +use dao_voting::pre_propose::{PreProposeInfo, ProposalCreationPolicy}; +use dao_voting::proposal::{ + SingleChoiceProposeMsg as ProposeMsg, DEFAULT_LIMIT, MAX_PROPOSAL_SIZE, +}; +use dao_voting::reply::{ + failed_pre_propose_module_hook_id, mask_proposal_execution_proposal_id, TaggedReplyId, +}; +use dao_voting::status::Status; +use dao_voting::threshold::Threshold; +use dao_voting::voting::{get_total_power, get_voting_power, validate_voting_period, Vote, Votes}; + +use crate::msg::MigrateMsg; +use crate::proposal::{next_proposal_id, SingleChoiceProposal}; +use crate::state::{Config, CREATION_POLICY}; + +use crate::v1_state::{ + v1_duration_to_v2, v1_expiration_to_v2, v1_status_to_v2, v1_threshold_to_v2, v1_votes_to_v2, +}; +use crate::{ + error::ContractError, + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + proposal::advance_proposal_id, + query::ProposalListResponse, + query::{ProposalResponse, VoteInfo, VoteListResponse, VoteResponse}, + state::{Ballot, BALLOTS, CONFIG, PROPOSALS, PROPOSAL_COUNT, PROPOSAL_HOOKS, VOTE_HOOKS}, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-proposal-single"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Message type used for firing hooks to this module's pre-propose +/// module, if one is installed. +type PreProposeHookMsg = dao_pre_propose_base::msg::ExecuteMsg; + +#[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)?; + + msg.threshold.validate()?; + + let dao = info.sender; + + let (min_voting_period, max_voting_period) = + validate_voting_period(msg.min_voting_period, msg.max_voting_period)?; + + let (initial_policy, pre_propose_messages) = msg + .pre_propose_info + .into_initial_policy_and_messages(dao.clone())?; + + let config = Config { + threshold: msg.threshold, + max_voting_period, + min_voting_period, + only_members_execute: msg.only_members_execute, + dao: dao.clone(), + allow_revoting: msg.allow_revoting, + close_proposal_on_execution_failure: msg.close_proposal_on_execution_failure, + }; + + // Initialize proposal count to zero so that queries return zero + // instead of None. + PROPOSAL_COUNT.save(deps.storage, &0)?; + CONFIG.save(deps.storage, &config)?; + CREATION_POLICY.save(deps.storage, &initial_policy)?; + + Ok(Response::default() + .add_submessages(pre_propose_messages) + .add_attribute("action", "instantiate") + .add_attribute("dao", dao)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Propose(ProposeMsg { + title, + description, + msgs, + proposer, + }) => execute_propose(deps, env, info.sender, title, description, msgs, proposer), + ExecuteMsg::Vote { + proposal_id, + vote, + rationale, + } => execute_vote(deps, env, info, proposal_id, vote, rationale), + ExecuteMsg::UpdateRationale { + proposal_id, + rationale, + } => execute_update_rationale(deps, info, proposal_id, rationale), + ExecuteMsg::Execute { proposal_id } => execute_execute(deps, env, info, proposal_id), + ExecuteMsg::Close { proposal_id } => execute_close(deps, env, info, proposal_id), + ExecuteMsg::UpdateConfig { + threshold, + max_voting_period, + min_voting_period, + only_members_execute, + allow_revoting, + dao, + close_proposal_on_execution_failure, + } => execute_update_config( + deps, + info, + threshold, + max_voting_period, + min_voting_period, + only_members_execute, + allow_revoting, + dao, + close_proposal_on_execution_failure, + ), + ExecuteMsg::UpdatePreProposeInfo { info: new_info } => { + execute_update_proposal_creation_policy(deps, info, new_info) + } + ExecuteMsg::AddProposalHook { address } => { + execute_add_proposal_hook(deps, env, info, address) + } + ExecuteMsg::RemoveProposalHook { address } => { + execute_remove_proposal_hook(deps, env, info, address) + } + ExecuteMsg::AddVoteHook { address } => execute_add_vote_hook(deps, env, info, address), + ExecuteMsg::RemoveVoteHook { address } => { + execute_remove_vote_hook(deps, env, info, address) + } + } +} + +pub fn execute_propose( + deps: DepsMut, + env: Env, + sender: Addr, + title: String, + description: String, + msgs: Vec>, + proposer: Option, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let proposal_creation_policy = CREATION_POLICY.load(deps.storage)?; + + // Check that the sender is permitted to create proposals. + if !proposal_creation_policy.is_permitted(&sender) { + return Err(ContractError::Unauthorized {}); + } + + // Determine the appropriate proposer. If this is coming from our + // pre-propose module, it must be specified. Otherwise, the + // proposer should not be specified. + let proposer = match (proposer, &proposal_creation_policy) { + (None, ProposalCreationPolicy::Anyone {}) => sender.clone(), + // `is_permitted` above checks that an allowed module is + // actually sending the propose message. + (Some(proposer), ProposalCreationPolicy::Module { .. }) => { + deps.api.addr_validate(&proposer)? + } + _ => return Err(ContractError::InvalidProposer {}), + }; + + let voting_module: Addr = deps.querier.query_wasm_smart( + config.dao.clone(), + &dao_interface::msg::QueryMsg::VotingModule {}, + )?; + + // Voting modules are not required to implement this + // query. Lacking an implementation they are active by default. + let active_resp: IsActiveResponse = deps + .querier + .query_wasm_smart(voting_module, &dao_interface::voting::Query::IsActive {}) + .unwrap_or(IsActiveResponse { active: true }); + + if !active_resp.active { + return Err(ContractError::InactiveDao {}); + } + + let expiration = config.max_voting_period.after(&env.block); + + let total_power = get_total_power(deps.as_ref(), &config.dao, Some(env.block.height))?; + + let proposal = { + // Limit mutability to this block. + let mut proposal = SingleChoiceProposal { + title, + description, + proposer: proposer.clone(), + start_height: env.block.height, + min_voting_period: config.min_voting_period.map(|min| min.after(&env.block)), + expiration, + threshold: config.threshold, + total_power, + msgs, + status: Status::Open, + votes: Votes::zero(), + allow_revoting: config.allow_revoting, + }; + // Update the proposal's status. Addresses case where proposal + // expires on the same block as it is created. + proposal.update_status(&env.block); + proposal + }; + let id = advance_proposal_id(deps.storage)?; + + // Limit the size of proposals. + // + // The Juno mainnet has a larger limit for data that can be + // uploaded as part of an execute message than it does for data + // that can be queried as part of a query. This means that without + // this check it is possible to create a proposal that can not be + // queried. + // + // The size selected was determined by uploading versions of this + // contract to the Juno mainnet until queries worked within a + // reasonable margin of error. + // + // `to_vec` is the method used by cosmwasm to convert a struct + // into it's byte representation in storage. + let proposal_size = cosmwasm_std::to_vec(&proposal)?.len() as u64; + if proposal_size > MAX_PROPOSAL_SIZE { + return Err(ContractError::ProposalTooLarge { + size: proposal_size, + max: MAX_PROPOSAL_SIZE, + }); + } + + PROPOSALS.save(deps.storage, id, &proposal)?; + + let hooks = new_proposal_hooks(PROPOSAL_HOOKS, deps.storage, id, proposer.as_str())?; + + Ok(Response::default() + .add_submessages(hooks) + .add_attribute("action", "propose") + .add_attribute("sender", sender) + .add_attribute("proposal_id", id.to_string()) + .add_attribute("status", proposal.status.to_string())) +} + +pub fn execute_execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, +) -> Result { + let mut prop = PROPOSALS + .may_load(deps.storage, proposal_id)? + .ok_or(ContractError::NoSuchProposal { id: proposal_id })?; + + let config = CONFIG.load(deps.storage)?; + if config.only_members_execute { + let power = get_voting_power( + deps.as_ref(), + info.sender.clone(), + &config.dao, + Some(prop.start_height), + )?; + if power.is_zero() { + return Err(ContractError::Unauthorized {}); + } + } + + // Check here that the proposal is passed. Allow it to be executed + // even if it is expired so long as it passed during its voting + // period. + let old_status = prop.status; + prop.update_status(&env.block); + if prop.status != Status::Passed { + return Err(ContractError::NotPassed {}); + } + + prop.status = Status::Executed; + + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + let response = { + if !prop.msgs.is_empty() { + let execute_message = WasmMsg::Execute { + contract_addr: config.dao.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::ExecuteProposalHook { + msgs: prop.msgs, + })?, + funds: vec![], + }; + match config.close_proposal_on_execution_failure { + true => { + let masked_proposal_id = mask_proposal_execution_proposal_id(proposal_id); + Response::default() + .add_submessage(SubMsg::reply_on_error(execute_message, masked_proposal_id)) + } + false => Response::default().add_message(execute_message), + } + } else { + Response::default() + } + }; + + let hooks = proposal_status_changed_hooks( + PROPOSAL_HOOKS, + deps.storage, + proposal_id, + old_status.to_string(), + prop.status.to_string(), + )?; + + // Add prepropose / deposit module hook which will handle deposit refunds. + let proposal_creation_policy = CREATION_POLICY.load(deps.storage)?; + let hooks = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => hooks, + ProposalCreationPolicy::Module { addr } => { + let msg = to_binary(&PreProposeHookMsg::ProposalCompletedHook { + proposal_id, + new_status: prop.status, + })?; + let mut hooks = hooks; + hooks.push(SubMsg::reply_on_error( + WasmMsg::Execute { + contract_addr: addr.into_string(), + msg, + funds: vec![], + }, + failed_pre_propose_module_hook_id(), + )); + hooks + } + }; + + Ok(response + .add_submessages(hooks) + .add_attribute("action", "execute") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("dao", config.dao)) +} + +pub fn execute_vote( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, + vote: Vote, + rationale: Option, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let mut prop = PROPOSALS + .may_load(deps.storage, proposal_id)? + .ok_or(ContractError::NoSuchProposal { id: proposal_id })?; + + // Allow voting on proposals until they expire. + // Voting on a non-open proposal will never change + // their outcome as if an outcome has been determined, + // it is because no possible sequence of votes may + // cause a different one. This then serves to allow + // for better tallies of opinions in the event that a + // proposal passes or is rejected early. + if prop.expiration.is_expired(&env.block) { + return Err(ContractError::Expired { id: proposal_id }); + } + + let vote_power = get_voting_power( + deps.as_ref(), + info.sender.clone(), + &config.dao, + Some(prop.start_height), + )?; + if vote_power.is_zero() { + return Err(ContractError::NotRegistered {}); + } + + BALLOTS.update(deps.storage, (proposal_id, &info.sender), |bal| match bal { + Some(current_ballot) => { + if prop.allow_revoting { + if current_ballot.vote == vote { + // Don't allow casting the same vote more than + // once. This seems liable to be confusing + // behavior. + Err(ContractError::AlreadyCast {}) + } else { + // Remove the old vote if this is a re-vote. + prop.votes + .remove_vote(current_ballot.vote, current_ballot.power); + Ok(Ballot { + power: vote_power, + vote, + // Roll over the previous rationale. If + // you're changing your vote, you've also + // likely changed your thinking. + rationale: rationale.clone(), + }) + } + } else { + Err(ContractError::AlreadyVoted {}) + } + } + None => Ok(Ballot { + power: vote_power, + vote, + rationale: rationale.clone(), + }), + })?; + + let old_status = prop.status; + + prop.votes.add_vote(vote, vote_power); + prop.update_status(&env.block); + + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + let new_status = prop.status; + let change_hooks = proposal_status_changed_hooks( + PROPOSAL_HOOKS, + deps.storage, + proposal_id, + old_status.to_string(), + new_status.to_string(), + )?; + + let vote_hooks = new_vote_hooks( + VOTE_HOOKS, + deps.storage, + proposal_id, + info.sender.to_string(), + vote.to_string(), + )?; + + Ok(Response::default() + .add_submessages(change_hooks) + .add_submessages(vote_hooks) + .add_attribute("action", "vote") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("position", vote.to_string()) + .add_attribute("rationale", rationale.as_deref().unwrap_or("_none")) + .add_attribute("status", prop.status.to_string())) +} + +pub fn execute_update_rationale( + deps: DepsMut, + info: MessageInfo, + proposal_id: u64, + rationale: Option, +) -> Result { + BALLOTS.update( + deps.storage, + // info.sender can't be forged so we implicitly access control + // with the key. + (proposal_id, &info.sender), + |ballot| match ballot { + Some(ballot) => Ok(Ballot { + rationale: rationale.clone(), + ..ballot + }), + None => Err(ContractError::NoSuchVote { + id: proposal_id, + voter: info.sender.to_string(), + }), + }, + )?; + + Ok(Response::default() + .add_attribute("action", "update_rationale") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("rationale", rationale.as_deref().unwrap_or("_none"))) +} + +pub fn execute_close( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, +) -> Result { + let mut prop = PROPOSALS.load(deps.storage, proposal_id)?; + + // Update status to ensure that proposals which were open and have + // expired are moved to "rejected." + prop.update_status(&env.block); + if prop.status != Status::Rejected { + return Err(ContractError::WrongCloseStatus {}); + } + + let old_status = prop.status; + + prop.status = Status::Closed; + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + let hooks = proposal_status_changed_hooks( + PROPOSAL_HOOKS, + deps.storage, + proposal_id, + old_status.to_string(), + prop.status.to_string(), + )?; + + // Add prepropose / deposit module hook which will handle deposit refunds. + let proposal_creation_policy = CREATION_POLICY.load(deps.storage)?; + let hooks = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => hooks, + ProposalCreationPolicy::Module { addr } => { + let msg = to_binary(&PreProposeHookMsg::ProposalCompletedHook { + proposal_id, + new_status: prop.status, + })?; + let mut hooks = hooks; + hooks.push(SubMsg::reply_on_error( + WasmMsg::Execute { + contract_addr: addr.into_string(), + msg, + funds: vec![], + }, + failed_pre_propose_module_hook_id(), + )); + hooks + } + }; + + Ok(Response::default() + .add_submessages(hooks) + .add_attribute("action", "close") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string())) +} + +#[allow(clippy::too_many_arguments)] +pub fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + threshold: Threshold, + max_voting_period: Duration, + min_voting_period: Option, + only_members_execute: bool, + allow_revoting: bool, + dao: String, + close_proposal_on_execution_failure: bool, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Only the DAO may call this method. + if info.sender != config.dao { + return Err(ContractError::Unauthorized {}); + } + threshold.validate()?; + let dao = deps.api.addr_validate(&dao)?; + + let (min_voting_period, max_voting_period) = + validate_voting_period(min_voting_period, max_voting_period)?; + + CONFIG.save( + deps.storage, + &Config { + threshold, + max_voting_period, + min_voting_period, + only_members_execute, + allow_revoting, + dao, + close_proposal_on_execution_failure, + }, + )?; + + Ok(Response::default() + .add_attribute("action", "update_config") + .add_attribute("sender", info.sender)) +} + +pub fn execute_update_proposal_creation_policy( + deps: DepsMut, + info: MessageInfo, + new_info: PreProposeInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + return Err(ContractError::Unauthorized {}); + } + + let (initial_policy, messages) = new_info.into_initial_policy_and_messages(config.dao)?; + CREATION_POLICY.save(deps.storage, &initial_policy)?; + + Ok(Response::default() + .add_submessages(messages) + .add_attribute("action", "update_proposal_creation_policy") + .add_attribute("sender", info.sender) + .add_attribute("new_policy", format!("{initial_policy:?}"))) +} + +pub fn add_hook( + hooks: Hooks, + storage: &mut dyn Storage, + validated_address: Addr, +) -> Result<(), ContractError> { + hooks + .add_hook(storage, validated_address) + .map_err(ContractError::HookError)?; + Ok(()) +} + +pub fn remove_hook( + hooks: Hooks, + storage: &mut dyn Storage, + validate_address: Addr, +) -> Result<(), ContractError> { + hooks + .remove_hook(storage, validate_address) + .map_err(ContractError::HookError)?; + Ok(()) +} + +pub fn execute_add_proposal_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + address: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + // Only DAO can add hooks + return Err(ContractError::Unauthorized {}); + } + + let validated_address = deps.api.addr_validate(&address)?; + + add_hook(PROPOSAL_HOOKS, deps.storage, validated_address)?; + + Ok(Response::default() + .add_attribute("action", "add_proposal_hook") + .add_attribute("address", address)) +} + +pub fn execute_remove_proposal_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + address: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + // Only DAO can remove hooks + return Err(ContractError::Unauthorized {}); + } + + let validated_address = deps.api.addr_validate(&address)?; + + remove_hook(PROPOSAL_HOOKS, deps.storage, validated_address)?; + + Ok(Response::default() + .add_attribute("action", "remove_proposal_hook") + .add_attribute("address", address)) +} + +pub fn execute_add_vote_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + address: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + // Only DAO can add hooks + return Err(ContractError::Unauthorized {}); + } + + let validated_address = deps.api.addr_validate(&address)?; + + add_hook(VOTE_HOOKS, deps.storage, validated_address)?; + + Ok(Response::default() + .add_attribute("action", "add_vote_hook") + .add_attribute("address", address)) +} + +pub fn execute_remove_vote_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + address: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + // Only DAO can remove hooks + return Err(ContractError::Unauthorized {}); + } + + let validated_address = deps.api.addr_validate(&address)?; + + remove_hook(VOTE_HOOKS, deps.storage, validated_address)?; + + Ok(Response::default() + .add_attribute("action", "remove_vote_hook") + .add_attribute("address", address)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => query_config(deps), + QueryMsg::Dao {} => query_dao(deps), + QueryMsg::Proposal { proposal_id } => query_proposal(deps, env, proposal_id), + QueryMsg::ListProposals { start_after, limit } => { + query_list_proposals(deps, env, start_after, limit) + } + QueryMsg::NextProposalId {} => query_next_proposal_id(deps), + QueryMsg::ProposalCount {} => query_proposal_count(deps), + QueryMsg::GetVote { proposal_id, voter } => query_vote(deps, proposal_id, voter), + QueryMsg::ListVotes { + proposal_id, + start_after, + limit, + } => query_list_votes(deps, proposal_id, start_after, limit), + QueryMsg::Info {} => query_info(deps), + QueryMsg::ReverseProposals { + start_before, + limit, + } => query_reverse_proposals(deps, env, start_before, limit), + QueryMsg::ProposalCreationPolicy {} => query_creation_policy(deps), + QueryMsg::ProposalHooks {} => to_binary(&PROPOSAL_HOOKS.query_hooks(deps)?), + QueryMsg::VoteHooks {} => to_binary(&VOTE_HOOKS.query_hooks(deps)?), + } +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&config) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&config.dao) +} + +pub fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult { + let proposal = PROPOSALS.load(deps.storage, id)?; + to_binary(&proposal.into_response(&env.block, id)) +} + +pub fn query_creation_policy(deps: Deps) -> StdResult { + let policy = CREATION_POLICY.load(deps.storage)?; + to_binary(&policy) +} + +pub fn query_list_proposals( + deps: Deps, + env: Env, + start_after: Option, + limit: Option, +) -> StdResult { + let min = start_after.map(Bound::exclusive); + let limit = limit.unwrap_or(DEFAULT_LIMIT); + let props: Vec = PROPOSALS + .range(deps.storage, min, None, cosmwasm_std::Order::Ascending) + .take(limit as usize) + .collect::, _>>()? + .into_iter() + .map(|(id, proposal)| proposal.into_response(&env.block, id)) + .collect(); + + to_binary(&ProposalListResponse { proposals: props }) +} + +pub fn query_reverse_proposals( + deps: Deps, + env: Env, + start_before: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT); + let max = start_before.map(Bound::exclusive); + let props: Vec = PROPOSALS + .range(deps.storage, None, max, cosmwasm_std::Order::Descending) + .take(limit as usize) + .collect::, _>>()? + .into_iter() + .map(|(id, proposal)| proposal.into_response(&env.block, id)) + .collect(); + + to_binary(&ProposalListResponse { proposals: props }) +} + +pub fn query_proposal_count(deps: Deps) -> StdResult { + let proposal_count = PROPOSAL_COUNT.load(deps.storage)?; + to_binary(&proposal_count) +} + +pub fn query_next_proposal_id(deps: Deps) -> StdResult { + to_binary(&next_proposal_id(deps.storage)?) +} + +pub fn query_vote(deps: Deps, proposal_id: u64, voter: String) -> StdResult { + let voter = deps.api.addr_validate(&voter)?; + let ballot = BALLOTS.may_load(deps.storage, (proposal_id, &voter))?; + let vote = ballot.map(|ballot| VoteInfo { + voter, + vote: ballot.vote, + power: ballot.power, + rationale: ballot.rationale, + }); + to_binary(&VoteResponse { vote }) +} + +pub fn query_list_votes( + deps: Deps, + proposal_id: u64, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT); + let start_after = start_after + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + let min = start_after.as_ref().map(Bound::<&Addr>::exclusive); + + let votes = BALLOTS + .prefix(proposal_id) + .range(deps.storage, min, None, cosmwasm_std::Order::Ascending) + .take(limit as usize) + .map(|item| { + let (voter, ballot) = item?; + Ok(VoteInfo { + voter, + vote: ballot.vote, + power: ballot.power, + rationale: ballot.rationale, + }) + }) + .collect::>>()?; + + to_binary(&VoteListResponse { votes }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + let ContractVersion { version, .. } = get_contract_version(deps.storage)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + match msg { + MigrateMsg::FromV1 { + close_proposal_on_execution_failure, + pre_propose_info, + } => { + // `CONTRACT_VERSION` here is from the data section of the + // blob we are migrating to. `version` is from storage. If + // the version in storage matches the version in the blob + // we are not upgrading. + if version == CONTRACT_VERSION { + return Err(ContractError::AlreadyMigrated {}); + } + + // Update the stored config to have the new + // `close_proposal_on_execution_falure` field. + let current_config = v1::state::CONFIG.load(deps.storage)?; + CONFIG.save( + deps.storage, + &Config { + threshold: v1_threshold_to_v2(current_config.threshold), + max_voting_period: v1_duration_to_v2(current_config.max_voting_period), + min_voting_period: current_config.min_voting_period.map(v1_duration_to_v2), + only_members_execute: current_config.only_members_execute, + allow_revoting: current_config.allow_revoting, + dao: current_config.dao.clone(), + close_proposal_on_execution_failure, + }, + )?; + + let (initial_policy, pre_propose_messages) = + pre_propose_info.into_initial_policy_and_messages(current_config.dao)?; + CREATION_POLICY.save(deps.storage, &initial_policy)?; + + // Update the module's proposals to v2. + + let current_proposals = v1::state::PROPOSALS + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + + // Based on gas usage testing, we estimate that we will be + // able to migrate ~4200 proposals at a time before + // reaching the block max_gas limit. + current_proposals + .into_iter() + .try_for_each::<_, Result<_, ContractError>>(|(id, prop)| { + if prop + .deposit_info + .map(|info| !info.deposit.is_zero()) + .unwrap_or(false) + && prop.status != voting_v1::Status::Closed + && prop.status != voting_v1::Status::Executed + { + // No migration path for outstanding + // deposits. + return Err(ContractError::PendingProposals {}); + } + + let migrated_proposal = SingleChoiceProposal { + title: prop.title, + description: prop.description, + proposer: prop.proposer, + start_height: prop.start_height, + min_voting_period: prop.min_voting_period.map(v1_expiration_to_v2), + expiration: v1_expiration_to_v2(prop.expiration), + threshold: v1_threshold_to_v2(prop.threshold), + total_power: prop.total_power, + msgs: prop.msgs, + status: v1_status_to_v2(prop.status), + votes: v1_votes_to_v2(prop.votes), + allow_revoting: prop.allow_revoting, + }; + + PROPOSALS + .save(deps.storage, id, &migrated_proposal) + .map_err(|e| e.into()) + })?; + + Ok(Response::default() + .add_attribute("action", "migrate") + .add_attribute("from", "v1") + .add_submessages(pre_propose_messages)) + } + + MigrateMsg::FromCompatible {} => Ok(Response::default() + .add_attribute("action", "migrate") + .add_attribute("from", "compatible")), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + let repl = TaggedReplyId::new(msg.id)?; + match repl { + TaggedReplyId::FailedProposalExecution(proposal_id) => { + PROPOSALS.update(deps.storage, proposal_id, |prop| match prop { + Some(mut prop) => { + prop.status = Status::ExecutionFailed; + + Ok(prop) + } + None => Err(ContractError::NoSuchProposal { id: proposal_id }), + })?; + + Ok(Response::new().add_attribute("proposal_execution_failed", proposal_id.to_string())) + } + TaggedReplyId::FailedProposalHook(idx) => { + let addr = PROPOSAL_HOOKS.remove_hook_by_index(deps.storage, idx)?; + Ok(Response::new().add_attribute("removed_proposal_hook", format!("{addr}:{idx}"))) + } + TaggedReplyId::FailedVoteHook(idx) => { + let addr = VOTE_HOOKS.remove_hook_by_index(deps.storage, idx)?; + Ok(Response::new().add_attribute("removed_vote_hook", format!("{addr}:{idx}"))) + } + TaggedReplyId::PreProposeModuleInstantiation => { + let res = parse_reply_instantiate_data(msg)?; + + let module = deps.api.addr_validate(&res.contract_address)?; + CREATION_POLICY.save( + deps.storage, + &ProposalCreationPolicy::Module { addr: module }, + )?; + + // per the cosmwasm docs, we shouldn't have to forward + // data like this, yet here we are and it does not work if + // we do not. + // + // + match res.data { + Some(data) => Ok(Response::new() + .add_attribute("update_pre_propose_module", res.contract_address) + .set_data(data)), + None => Ok(Response::new() + .add_attribute("update_pre_propose_module", res.contract_address)), + } + } + TaggedReplyId::FailedPreProposeModuleHook => { + let addr = match CREATION_POLICY.load(deps.storage)? { + ProposalCreationPolicy::Anyone {} => { + // Something is off if we're getting this + // reply and we don't have a pre-propose + // module installed. This should be + // unreachable. + return Err(ContractError::InvalidReplyID { + id: failed_pre_propose_module_hook_id(), + }); + } + ProposalCreationPolicy::Module { addr } => { + // If we are here, our pre-propose module has + // errored while receiving a proposal + // hook. Rest in peace pre-propose module. + CREATION_POLICY.save(deps.storage, &ProposalCreationPolicy::Anyone {})?; + addr + } + }; + Ok(Response::new().add_attribute("failed_prepropose_hook", format!("{addr}"))) + } + } +} diff --git a/contracts/proposal/dao-proposal-single/src/error.rs b/contracts/proposal/dao-proposal-single/src/error.rs new file mode 100644 index 000000000..8af18058f --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/error.rs @@ -0,0 +1,92 @@ +use std::u64; + +use cosmwasm_std::StdError; +use cw_hooks::HookError; +use cw_utils::ParseReplyError; +use dao_voting::reply::error::TagError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + ParseReplyError(#[from] ParseReplyError), + + #[error(transparent)] + HookError(#[from] HookError), + + #[error("unauthorized")] + Unauthorized {}, + + #[error(transparent)] + ThresholdError(#[from] dao_voting::threshold::ThresholdError), + + #[error(transparent)] + VotingError(#[from] dao_voting::error::VotingError), + + #[error("no such proposal ({id})")] + NoSuchProposal { id: u64 }, + + #[error("no vote exists for proposal ({id}) and voter ({voter})")] + NoSuchVote { id: u64, voter: String }, + + #[error("proposal is ({size}) bytes, must be <= ({max}) bytes")] + ProposalTooLarge { size: u64, max: u64 }, + + #[error("Proposal ({id}) is expired")] + Expired { id: u64 }, + + #[error("not registered to vote (no voting power) at time of proposal creation")] + NotRegistered {}, + + #[error("already voted. this proposal does not support revoting")] + AlreadyVoted {}, + + #[error("already cast a vote with that option. change your vote to revote")] + AlreadyCast {}, + + #[error("proposal is not in 'passed' state")] + NotPassed {}, + + #[error("proposal has already been executed")] + AlreadyExecuted {}, + + #[error("proposal is closed")] + Closed {}, + + #[error("only rejected proposals may be closed")] + WrongCloseStatus {}, + + #[error("the DAO is currently inactive, you cannot create proposals")] + InactiveDao {}, + + #[error("min_voting_period and max_voting_period must have the same units (height or time)")] + DurationUnitsConflict {}, + + #[error("min voting period must be less than or equal to max voting period")] + InvalidMinVotingPeriod {}, + + #[error( + "pre-propose modules must specify a proposer. lacking one, no proposer should be specified" + )] + InvalidProposer {}, + + #[error(transparent)] + Tag(#[from] TagError), + + #[error( + "all proposals with deposits must be completed out (closed or executed) before migration" + )] + PendingProposals {}, + + #[error("received a failed proposal hook reply with an invalid hook index: ({idx})")] + InvalidHookIndex { idx: u64 }, + + #[error("received a reply failure with an invalid ID: ({id})")] + InvalidReplyID { id: u64 }, + + #[error("can not migrate. current version is up to date")] + AlreadyMigrated {}, +} diff --git a/contracts/proposal/dao-proposal-single/src/lib.rs b/contracts/proposal/dao-proposal-single/src/lib.rs new file mode 100644 index 000000000..c9076cf76 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/lib.rs @@ -0,0 +1,15 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod proposal; +pub mod query; + +#[cfg(test)] +mod testing; + +pub mod state; +mod v1_state; + +pub use crate::error::ContractError; diff --git a/contracts/proposal/dao-proposal-single/src/msg.rs b/contracts/proposal/dao-proposal-single/src/msg.rs new file mode 100644 index 000000000..c0be9b317 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/msg.rs @@ -0,0 +1,224 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cw_utils::Duration; +use dao_dao_macros::proposal_module_query; +use dao_voting::{ + pre_propose::PreProposeInfo, proposal::SingleChoiceProposeMsg, threshold::Threshold, + voting::Vote, +}; + +#[cw_serde] +pub struct InstantiateMsg { + /// The threshold a proposal must reach to complete. + pub threshold: Threshold, + /// The default maximum amount of time a proposal may be voted on + /// before expiring. + pub max_voting_period: Duration, + /// The minimum amount of time a proposal must be open before + /// passing. A proposal may fail before this amount of time has + /// elapsed, but it will not pass. This can be useful for + /// preventing governance attacks wherein an attacker aquires a + /// large number of tokens and forces a proposal through. + pub min_voting_period: Option, + /// If set to true only members may execute passed + /// proposals. Otherwise, any address may execute a passed + /// proposal. + pub only_members_execute: bool, + /// Allows changing votes before the proposal expires. If this is + /// enabled proposals will not be able to complete early as final + /// vote information is not known until the time of proposal + /// expiration. + pub allow_revoting: bool, + /// Information about what addresses may create proposals. + pub pre_propose_info: PreProposeInfo, + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + pub close_proposal_on_execution_failure: bool, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Creates a proposal in the module. + Propose(SingleChoiceProposeMsg), + /// Votes on a proposal. Voting power is determined by the DAO's + /// voting power module. + Vote { + /// The ID of the proposal to vote on. + proposal_id: u64, + /// The senders position on the proposal. + vote: Vote, + /// An optional rationale for why this vote was cast. This can + /// be updated, set, or removed later by the address casting + /// the vote. + rationale: Option, + }, + /// Updates the sender's rationale for their vote on the specified + /// proposal. Errors if no vote vote has been cast. + UpdateRationale { + proposal_id: u64, + rationale: Option, + }, + /// Causes the messages associated with a passed proposal to be + /// executed by the DAO. + Execute { + /// The ID of the proposal to execute. + proposal_id: u64, + }, + /// Closes a proposal that has failed (either not passed or timed + /// out). If applicable this will cause the proposal deposit + /// associated wth said proposal to be returned. + Close { + /// The ID of the proposal to close. + proposal_id: u64, + }, + /// Updates the governance module's config. + UpdateConfig { + /// The new proposal passing threshold. This will only apply + /// to proposals created after the config update. + threshold: Threshold, + /// The default maximum amount of time a proposal may be voted + /// on before expiring. This will only apply to proposals + /// created after the config update. + max_voting_period: Duration, + /// The minimum amount of time a proposal must be open before + /// passing. A proposal may fail before this amount of time has + /// elapsed, but it will not pass. This can be useful for + /// preventing governance attacks wherein an attacker aquires a + /// large number of tokens and forces a proposal through. + min_voting_period: Option, + /// If set to true only members may execute passed + /// proposals. Otherwise, any address may execute a passed + /// proposal. Applies to all outstanding and future proposals. + only_members_execute: bool, + /// Allows changing votes before the proposal expires. If this is + /// enabled proposals will not be able to complete early as final + /// vote information is not known until the time of proposal + /// expiration. + allow_revoting: bool, + /// The address if tge DAO that this governance module is + /// associated with. + dao: String, + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + close_proposal_on_execution_failure: bool, + }, + /// Update's the proposal creation policy used for this + /// module. Only the DAO may call this method. + UpdatePreProposeInfo { info: PreProposeInfo }, + /// Adds an address as a consumer of proposal hooks. Consumers of + /// proposal hooks have hook messages executed on them whenever + /// the status of a proposal changes or a proposal is created. If + /// a consumer contract errors when handling a hook message it + /// will be removed from the list of consumers. + AddProposalHook { address: String }, + /// Removes a consumer of proposal hooks. + RemoveProposalHook { address: String }, + /// Adds an address as a consumer of vote hooks. Consumers of vote + /// hooks have hook messages executed on them whenever the a vote + /// is cast. If a consumer contract errors when handling a hook + /// message it will be removed from the list of consumers. + AddVoteHook { address: String }, + /// Removed a consumer of vote hooks. + RemoveVoteHook { address: String }, +} + +#[proposal_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Gets the proposal module's config. + #[returns(crate::state::Config)] + Config {}, + /// Gets information about a proposal. + #[returns(crate::query::ProposalResponse)] + Proposal { proposal_id: u64 }, + /// Lists all the proposals that have been cast in this + /// module. + #[returns(crate::query::ProposalListResponse)] + ListProposals { + /// The proposal ID to start listing proposals after. For + /// example, if this is set to 2 proposals with IDs 3 and + /// higher will be returned. + start_after: Option, + /// The maximum number of proposals to return as part of this + /// query. If no limit is set a max of 30 proposals will be + /// returned. + limit: Option, + }, + /// Lists all of the proposals that have been cast in this module + /// in decending order of proposal ID. + #[returns(crate::query::ProposalListResponse)] + ReverseProposals { + /// The proposal ID to start listing proposals before. For + /// example, if this is set to 6 proposals with IDs 5 and + /// lower will be returned. + start_before: Option, + /// The maximum number of proposals to return as part of this + /// query. If no limit is set a max of 30 proposals will be + /// returned. + limit: Option, + }, + /// Returns a voters position on a propsal. + #[returns(crate::query::VoteResponse)] + GetVote { proposal_id: u64, voter: String }, + /// Lists all of the votes that have been cast on a + /// proposal. + #[returns(crate::query::VoteListResponse)] + ListVotes { + /// The proposal to list the votes of. + proposal_id: u64, + /// The voter to start listing votes after. Ordering is done + /// alphabetically. + start_after: Option, + /// The maximum number of votes to return in response to this + /// query. If no limit is specified a max of 30 are returned. + limit: Option, + }, + /// Returns the number of proposals that have been created in this module. + #[returns(::std::primitive::u64)] + ProposalCount {}, + /// Gets the current proposal creation policy for this module. + #[returns(::dao_voting::pre_propose::ProposalCreationPolicy)] + ProposalCreationPolicy {}, + /// Lists all of the consumers of proposal hooks for this module. + #[returns(::cw_hooks::HooksResponse)] + ProposalHooks {}, + /// Lists all of the consumers of vote hooks for this module. + #[returns(::cw_hooks::HooksResponse)] + VoteHooks {}, +} + +#[cw_serde] +pub enum MigrateMsg { + FromV1 { + /// This field was not present in DAO DAO v1. To migrate, a + /// value must be specified. + /// + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + close_proposal_on_execution_failure: bool, + /// This field was not present in DAO DAO v1. To migrate, a + /// value must be specified. + /// + /// This contains information about how a pre-propose module may be configured. + /// If set to "AnyoneMayPropose", there will be no pre-propose module and consequently, + /// no deposit or membership checks when submitting a proposal. The "ModuleMayPropose" + /// option allows for instantiating a prepropose module which will handle deposit verification and return logic. + pre_propose_info: PreProposeInfo, + }, + FromCompatible {}, +} diff --git a/contracts/proposal/dao-proposal-single/src/proposal.rs b/contracts/proposal/dao-proposal-single/src/proposal.rs new file mode 100644 index 000000000..9ba174ad5 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/proposal.rs @@ -0,0 +1,1110 @@ +use crate::query::ProposalResponse; +use crate::state::PROPOSAL_COUNT; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, BlockInfo, CosmosMsg, Decimal, Empty, StdResult, Storage, Uint128}; +use cw_utils::Expiration; +use dao_voting::status::Status; +use dao_voting::threshold::{PercentageThreshold, Threshold}; +use dao_voting::voting::{does_vote_count_fail, does_vote_count_pass, Votes}; + +#[cw_serde] +pub struct SingleChoiceProposal { + pub title: String, + pub description: String, + /// The address that created this proposal. + pub proposer: Addr, + /// The block height at which this proposal was created. Voting + /// power queries should query for voting power at this block + /// height. + pub start_height: u64, + /// The minimum amount of time this proposal must remain open for + /// voting. The proposal may not pass unless this is expired or + /// None. + pub min_voting_period: Option, + /// The the time at which this proposal will expire and close for + /// additional votes. + pub expiration: Expiration, + /// The threshold at which this proposal will pass. + pub threshold: Threshold, + /// The total amount of voting power at the time of this + /// proposal's creation. + pub total_power: Uint128, + /// The messages that will be executed should this proposal pass. + pub msgs: Vec>, + pub status: Status, + pub votes: Votes, + pub allow_revoting: bool, +} + +pub fn next_proposal_id(store: &dyn Storage) -> StdResult { + Ok(PROPOSAL_COUNT.may_load(store)?.unwrap_or_default() + 1) +} + +pub fn advance_proposal_id(store: &mut dyn Storage) -> StdResult { + let id: u64 = next_proposal_id(store)?; + PROPOSAL_COUNT.save(store, &id)?; + Ok(id) +} + +impl SingleChoiceProposal { + /// Consumes the proposal and returns a version which may be used + /// in a query response. Why is this necessary? Proposal + /// statuses are only updated on vote, execute, and close + /// events; thus, it is possible that, if the proposal expires since + /// a vote has occurred, the status we read from the proposal status + /// may be out of date. This method recomputes the status so that + /// queries get accurate information. + pub fn into_response(mut self, block: &BlockInfo, id: u64) -> ProposalResponse { + self.update_status(block); + ProposalResponse { id, proposal: self } + } + + /// Gets the current status of the proposal. + pub fn current_status(&self, block: &BlockInfo) -> Status { + if self.status == Status::Open && self.is_passed(block) { + Status::Passed + } else if self.status == Status::Open + && (self.expiration.is_expired(block) || self.is_rejected(block)) + { + Status::Rejected + } else { + self.status + } + } + + /// Sets a proposals status to its current status. + pub fn update_status(&mut self, block: &BlockInfo) { + let new_status = self.current_status(block); + self.status = new_status + } + + /// Returns true iff this proposal is sure to pass (even before + /// expiration if no future sequence of possible votes can cause + /// it to fail). + pub fn is_passed(&self, block: &BlockInfo) -> bool { + // If re-voting is allowed nothing is known until the proposal + // has expired. + if self.allow_revoting && !self.expiration.is_expired(block) { + return false; + } + // If the min voting period is set and not expired the + // proposal can not yet be passed. This gives DAO members some + // time to remove liquidity / scheme on a recovery plan if a + // single actor accumulates enough tokens to unilaterally pass + // proposals. + if let Some(min) = self.min_voting_period { + if !min.is_expired(block) { + return false; + } + } + + match self.threshold { + Threshold::AbsolutePercentage { percentage } => { + let options = self.total_power - self.votes.abstain; + does_vote_count_pass(self.votes.yes, options, percentage) + } + Threshold::ThresholdQuorum { threshold, quorum } => { + if !does_vote_count_pass(self.votes.total(), self.total_power, quorum) { + return false; + } + + if self.expiration.is_expired(block) { + // If the quorum is met and the proposal is + // expired the number of votes needed to pass a + // proposal is compared to the number of votes on + // the proposal. + let options = self.votes.total() - self.votes.abstain; + does_vote_count_pass(self.votes.yes, options, threshold) + } else { + let options = self.total_power - self.votes.abstain; + does_vote_count_pass(self.votes.yes, options, threshold) + } + } + Threshold::AbsoluteCount { threshold } => self.votes.yes >= threshold, + } + } + + /// As above for the passed check, used to check if a proposal is + /// already rejected. + pub fn is_rejected(&self, block: &BlockInfo) -> bool { + // If re-voting is allowed and the proposal is not expired no + // information is known. + if self.allow_revoting && !self.expiration.is_expired(block) { + return false; + } + + match self.threshold { + Threshold::AbsolutePercentage { + percentage: percentage_needed, + } => { + let options = self.total_power - self.votes.abstain; + + // If there is a 100% passing threshold.. + if percentage_needed == PercentageThreshold::Percent(Decimal::percent(100)) { + if options == Uint128::zero() { + // and there are no possible votes (zero + // voting power or all abstain), then this + // proposal has been rejected. + return true; + } else { + // and there are possible votes, then this is + // rejected if there is a single no vote. + // + // We need this check becuase otherwise when + // we invert the threshold (`Decimal::one() - + // threshold`) we get a 0% requirement for no + // votes. Zero no votes do indeed meet a 0% + // threshold. + return self.votes.no >= Uint128::new(1); + } + } + + does_vote_count_fail(self.votes.no, options, percentage_needed) + } + Threshold::ThresholdQuorum { threshold, quorum } => { + match ( + does_vote_count_pass(self.votes.total(), self.total_power, quorum), + self.expiration.is_expired(block), + ) { + // Has met quorum and is expired. + (true, true) => { + // => consider only votes cast and see if no + // votes meet threshold. + let options = self.votes.total() - self.votes.abstain; + + // If there is a 100% passing threshold.. + if threshold == PercentageThreshold::Percent(Decimal::percent(100)) { + if options == Uint128::zero() { + // and there are no possible votes (zero + // voting power or all abstain), then this + // proposal has been rejected. + return true; + } else { + // and there are possible votes, then this is + // rejected if there is a single no vote. + // + // We need this check becuase + // otherwise when we invert the + // threshold (`Decimal::one() - + // threshold`) we get a 0% requirement + // for no votes. Zero no votes do + // indeed meet a 0% threshold. + return self.votes.no >= Uint128::new(1); + } + } + does_vote_count_fail(self.votes.no, options, threshold) + } + // Has met quorum and is not expired. + // | Hasn't met quorum and is not expired. + (true, false) | (false, false) => { + // => consider all possible votes and see if + // no votes meet threshold. + let options = self.total_power - self.votes.abstain; + + // If there is a 100% passing threshold.. + if threshold == PercentageThreshold::Percent(Decimal::percent(100)) { + if options == Uint128::zero() { + // and there are no possible votes (zero + // voting power or all abstain), then this + // proposal has been rejected. + return true; + } else { + // and there are possible votes, then this is + // rejected if there is a single no vote. + // + // We need this check because otherwise + // when we invert the threshold + // (`Decimal::one() - threshold`) we + // get a 0% requirement for no + // votes. Zero no votes do indeed meet + // a 0% threshold. + return self.votes.no >= Uint128::new(1); + } + } + + does_vote_count_fail(self.votes.no, options, threshold) + } + // Hasn't met quorum requirement and voting has closed => rejected. + (false, true) => true, + } + } + Threshold::AbsoluteCount { threshold } => { + // If all the outstanding votes voting yes would not + // cause this proposal to pass then it is rejected. + let outstanding_votes = self.total_power - self.votes.total(); + self.votes.yes + outstanding_votes < threshold + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use cosmwasm_std::{ + testing::{mock_dependencies, mock_env}, + Decimal, + }; + + fn setup_prop( + threshold: Threshold, + votes: Votes, + total_power: Uint128, + is_expired: bool, + min_voting_period_elapsed: bool, + allow_revoting: bool, + ) -> (SingleChoiceProposal, BlockInfo) { + let block = mock_env().block; + let expiration = match is_expired { + true => Expiration::AtHeight(block.height - 5), + false => Expiration::AtHeight(block.height + 100), + }; + let min_voting_period = match min_voting_period_elapsed { + true => Expiration::AtHeight(block.height - 5), + false => Expiration::AtHeight(block.height + 5), + }; + + let prop = SingleChoiceProposal { + title: "Demo".to_string(), + description: "Info".to_string(), + proposer: Addr::unchecked("test"), + start_height: 100, + expiration, + min_voting_period: Some(min_voting_period), + allow_revoting, + msgs: vec![], + status: Status::Open, + threshold, + total_power, + votes, + }; + (prop, block) + } + + fn check_is_passed( + threshold: Threshold, + votes: Votes, + total_power: Uint128, + is_expired: bool, + min_voting_period_elapsed: bool, + allow_revoting: bool, + ) -> bool { + let (prop, block) = setup_prop( + threshold, + votes, + total_power, + is_expired, + min_voting_period_elapsed, + allow_revoting, + ); + prop.is_passed(&block) + } + + fn check_is_rejected( + threshold: Threshold, + votes: Votes, + total_power: Uint128, + is_expired: bool, + min_voting_period_elapsed: bool, + allow_revoting: bool, + ) -> bool { + let (prop, block) = setup_prop( + threshold, + votes, + total_power, + is_expired, + min_voting_period_elapsed, + allow_revoting, + ); + prop.is_rejected(&block) + } + + #[test] + fn test_pass_majority_percentage() { + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + let votes = Votes { + yes: Uint128::new(7), + no: Uint128::new(4), + abstain: Uint128::new(2), + }; + + // 15 total votes. 7 yes and 2 abstain. Majority threshold. This + // should pass. + assert!(check_is_passed( + threshold.clone(), + votes.clone(), + Uint128::new(15), + false, + true, + false, + )); + // Proposal being expired should not effect those results. + assert!(check_is_passed( + threshold.clone(), + votes.clone(), + Uint128::new(15), + true, + true, + false + )); + + // More votes == higher threshold => not passed. + assert!(!check_is_passed( + threshold, + votes, + Uint128::new(17), + false, + true, + false + )); + } + + #[test] + fn test_min_voting_period_no_early_pass() { + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + let votes = Votes { + yes: Uint128::new(7), + no: Uint128::new(4), + abstain: Uint128::new(2), + }; + + // Does not pass if min voting period is not expired. + assert!(!check_is_passed( + threshold.clone(), + votes.clone(), + Uint128::new(15), + false, + false, + false, + )); + // Should not be rejected either. + assert!(!check_is_rejected( + threshold.clone(), + votes.clone(), + Uint128::new(15), + false, + false, + false, + )); + + // Min voting period being expired makes this pass. + assert!(check_is_passed( + threshold, + votes, + Uint128::new(15), + false, + true, + false + )); + } + + #[test] + fn test_min_voting_period_early_rejection() { + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + let votes = Votes { + yes: Uint128::new(4), + no: Uint128::new(7), + abstain: Uint128::new(2), + }; + + // Proposal has not passed. + assert!(!check_is_passed( + threshold.clone(), + votes.clone(), + Uint128::new(15), + false, + false, + false, + )); + // Should be rejected despite the min voting period not being + // passed. + assert!(check_is_rejected( + threshold, + votes, + Uint128::new(15), + false, + false, + false, + )); + } + + #[test] + fn test_revoting_majority_no_pass() { + // Revoting being allowed means that proposals may not be + // passed or rejected before they expire. + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + let votes = Votes { + yes: Uint128::new(7), + no: Uint128::new(4), + abstain: Uint128::new(2), + }; + + // 15 total votes. 7 yes and 2 abstain. Majority threshold. This + // should pass but revoting is enabled. + assert!(!check_is_passed( + threshold.clone(), + votes.clone(), + Uint128::new(15), + false, + true, + true, + )); + // Proposal being expired should cause the proposal to be + // passed as votes may no longer be cast. + assert!(check_is_passed( + threshold, + votes, + Uint128::new(15), + true, + true, + true + )); + } + + #[test] + fn test_revoting_majority_rejection() { + // Revoting being allowed means that proposals may not be + // passed or rejected before they expire. + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + let votes = Votes { + yes: Uint128::new(4), + no: Uint128::new(7), + abstain: Uint128::new(2), + }; + + // Not expired, revoting allowed => no rejection. + assert!(!check_is_rejected( + threshold.clone(), + votes.clone(), + Uint128::new(15), + false, + true, + true + )); + + // Expired, revoting allowed => rejection. + assert!(check_is_rejected( + threshold, + votes, + Uint128::new(15), + true, + true, + true + )); + } + + /// Simple checks for absolute count passing and failing + /// conditions. + #[test] + fn test_absolute_count_threshold() { + let threshold = Threshold::AbsoluteCount { + threshold: Uint128::new(10), + }; + + assert!(check_is_passed( + threshold.clone(), + Votes { + yes: Uint128::new(10), + no: Uint128::zero(), + abstain: Uint128::zero(), + }, + Uint128::new(100), + false, + true, + false + )); + + assert!(check_is_rejected( + threshold.clone(), + Votes { + yes: Uint128::new(9), + no: Uint128::new(1), + abstain: Uint128::zero() + }, + Uint128::new(10), + false, + true, + false + )); + + assert!(!check_is_rejected( + threshold.clone(), + Votes { + yes: Uint128::new(9), + no: Uint128::new(1), + abstain: Uint128::zero() + }, + Uint128::new(11), + false, + true, + false + )); + + assert!(!check_is_passed( + threshold, + Votes { + yes: Uint128::new(9), + no: Uint128::new(1), + abstain: Uint128::zero() + }, + Uint128::new(11), + false, + true, + false + )); + } + + /// Tests that revoting works as expected with an absolute count + /// style threshold. + #[test] + fn test_absolute_count_threshold_revoting() { + let threshold = Threshold::AbsoluteCount { + threshold: Uint128::new(10), + }; + + assert!(!check_is_passed( + threshold.clone(), + Votes { + yes: Uint128::new(10), + no: Uint128::zero(), + abstain: Uint128::zero(), + }, + Uint128::new(100), + false, + true, + true + )); + assert!(check_is_passed( + threshold.clone(), + Votes { + yes: Uint128::new(10), + no: Uint128::zero(), + abstain: Uint128::zero(), + }, + Uint128::new(100), + true, + true, + true + )); + + assert!(!check_is_rejected( + threshold.clone(), + Votes { + yes: Uint128::new(9), + no: Uint128::new(1), + abstain: Uint128::zero() + }, + Uint128::new(10), + false, + true, + true + )); + assert!(check_is_rejected( + threshold, + Votes { + yes: Uint128::new(9), + no: Uint128::new(1), + abstain: Uint128::zero() + }, + Uint128::new(10), + true, + true, + true + )); + } + + #[test] + fn test_tricky_pass() { + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::from_ratio(7u32, 13u32)), + }; + let votes = Votes { + yes: Uint128::new(7), + no: Uint128::new(6), + abstain: Uint128::zero(), + }; + assert!(check_is_passed( + threshold, + votes, + Uint128::new(13), + false, + true, + false + )) + } + + #[test] + fn test_weird_failure_rounding() { + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::from_ratio(6u32, 13u32)), + }; + let votes = Votes { + yes: Uint128::new(6), + no: Uint128::new(7), + abstain: Uint128::zero(), + }; + assert!(check_is_passed( + threshold.clone(), + votes.clone(), + Uint128::new(13), + false, + true, + false + )); + assert!(!check_is_rejected( + threshold, + votes, + Uint128::new(13), + false, + true, + false + )); + } + + #[test] + fn test_tricky_pass_majority() { + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + let votes = Votes { + yes: Uint128::new(7), + no: Uint128::new(6), + abstain: Uint128::zero(), + }; + assert!(check_is_passed( + threshold.clone(), + votes.clone(), + Uint128::new(13), + false, + true, + false + )); + assert!(!check_is_passed( + threshold, + votes, + Uint128::new(14), + false, + true, + false + )) + } + + #[test] + fn test_reject_majority_percentage() { + let percent = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + + // 4 YES, 7 NO, 2 ABSTAIN + let votes = Votes { + yes: Uint128::new(4), + no: Uint128::new(7), + abstain: Uint128::new(2), + }; + + // 15 total voting power + // 7 / (15 - 2) > 50% + // Expiry does not matter + assert!(check_is_rejected( + percent.clone(), + votes.clone(), + Uint128::new(15), + false, + true, + false, + )); + assert!(check_is_rejected( + percent.clone(), + votes.clone(), + Uint128::new(15), + true, + true, + false + )); + + // 17 total voting power + // 7 / (17 - 2) < 50% + assert!(!check_is_rejected( + percent.clone(), + votes.clone(), + Uint128::new(17), + false, + true, + false + )); + assert!(!check_is_rejected( + percent.clone(), + votes.clone(), + Uint128::new(17), + true, + true, + false + )); + + // Rejected if total was lower + assert!(check_is_rejected( + percent.clone(), + votes.clone(), + Uint128::new(14), + false, + true, + false + )); + assert!(check_is_rejected( + percent, + votes, + Uint128::new(14), + true, + true, + false + )); + } + + #[test] + fn proposal_passed_quorum() { + let quorum = Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(50)), + quorum: PercentageThreshold::Percent(Decimal::percent(40)), + }; + // all non-yes votes are counted for quorum + let passing = Votes { + yes: Uint128::new(7), + no: Uint128::new(3), + abstain: Uint128::new(2), + }; + // abstain votes are not counted for threshold => yes / (yes + no + veto) + let passes_ignoring_abstain = Votes { + yes: Uint128::new(6), + no: Uint128::new(6), + abstain: Uint128::new(5), + }; + // fails any way you look at it + let failing = Votes { + yes: Uint128::new(6), + no: Uint128::new(7), + abstain: Uint128::new(2), + }; + + // first, expired (voting period over) + // over quorum (40% of 30 = 12), over threshold (7/12 > 50%) + assert!(check_is_passed( + quorum.clone(), + passing.clone(), + Uint128::new(30), + true, + true, + false, + )); + // under quorum it is not passing (40% of 33 = 13.2 > 13) + assert!(!check_is_passed( + quorum.clone(), + passing.clone(), + Uint128::new(33), + true, + true, + false + )); + // over quorum, threshold passes if we ignore abstain + // 17 total votes w/ abstain => 40% quorum of 40 total + // 6 yes / (6 yes + 4 no + 2 votes) => 50% threshold + assert!(check_is_passed( + quorum.clone(), + passes_ignoring_abstain.clone(), + Uint128::new(40), + true, + true, + false, + )); + // over quorum, but under threshold fails also + assert!(!check_is_passed( + quorum.clone(), + failing, + Uint128::new(20), + true, + true, + false + )); + + // now, check with open voting period + // would pass if closed, but fail here, as remaining votes no -> fail + assert!(!check_is_passed( + quorum.clone(), + passing.clone(), + Uint128::new(30), + false, + true, + false + )); + assert!(!check_is_passed( + quorum.clone(), + passes_ignoring_abstain.clone(), + Uint128::new(40), + false, + true, + false + )); + // if we have threshold * total_weight as yes votes this must pass + assert!(check_is_passed( + quorum.clone(), + passing.clone(), + Uint128::new(14), + false, + true, + false + )); + // all votes have been cast, some abstain + assert!(check_is_passed( + quorum.clone(), + passes_ignoring_abstain, + Uint128::new(17), + false, + true, + false + )); + // 3 votes uncast, if they all vote no, we have 7 yes, 7 no+veto, 2 abstain (out of 16) + assert!(check_is_passed( + quorum, + passing, + Uint128::new(16), + false, + true, + false + )); + } + + #[test] + fn proposal_rejected_quorum() { + let quorum = Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(40)), + }; + // all non-yes votes are counted for quorum + let rejecting = Votes { + yes: Uint128::new(3), + no: Uint128::new(8), + abstain: Uint128::new(2), + }; + // abstain votes are not counted for threshold => yes / (yes + no) + let rejected_ignoring_abstain = Votes { + yes: Uint128::new(4), + no: Uint128::new(8), + abstain: Uint128::new(5), + }; + // fails any way you look at it + let failing = Votes { + yes: Uint128::new(5), + no: Uint128::new(8), + abstain: Uint128::new(2), + }; + + // first, expired (voting period over) + // over quorum (40% of 30 = 12), over threshold (7/11 > 50%) + assert!(check_is_rejected( + quorum.clone(), + rejecting.clone(), + Uint128::new(30), + true, + true, + false + )); + // Total power of 33. 13 total votes. 8 no votes, 3 yes, 2 + // abstain. 39.3% turnout. Expired. As it is expired we see if + // the 8 no votes excede the 50% failing threshold, which they + // do. + assert!(check_is_rejected( + quorum.clone(), + rejecting.clone(), + Uint128::new(33), + true, + true, + false + )); + + // over quorum, threshold passes if we ignore abstain + // 17 total votes w/ abstain => 40% quorum of 40 total + // 6 no / (6 no + 4 yes + 2 votes) => 50% threshold + assert!(check_is_rejected( + quorum.clone(), + rejected_ignoring_abstain.clone(), + Uint128::new(40), + true, + true, + false + )); + + // Over quorum, but under threshold fails if the proposal is + // not expired. If the proposal is expired though it passes as + // the total vote count used is the number of votes, and not + // the total number of votes avaliable. + assert!(check_is_rejected( + quorum.clone(), + failing.clone(), + Uint128::new(20), + true, + true, + false + )); + assert!(!check_is_rejected( + quorum.clone(), + failing, + Uint128::new(20), + false, + true, + false + )); + + // Voting is still open so assume rest of votes are yes + // threshold not reached + assert!(!check_is_rejected( + quorum.clone(), + rejecting.clone(), + Uint128::new(30), + false, + true, + false + )); + assert!(!check_is_rejected( + quorum.clone(), + rejected_ignoring_abstain.clone(), + Uint128::new(40), + false, + true, + false + )); + // if we have threshold * total_weight as no votes this must reject + assert!(check_is_rejected( + quorum.clone(), + rejecting.clone(), + Uint128::new(14), + false, + true, + false + )); + // all votes have been cast, some abstain + assert!(check_is_rejected( + quorum.clone(), + rejected_ignoring_abstain, + Uint128::new(17), + false, + true, + false + )); + // 3 votes uncast, if they all vote yes, we have 7 no, 7 yes+veto, 2 abstain (out of 16) + assert!(check_is_rejected( + quorum, + rejecting, + Uint128::new(16), + false, + true, + false + )); + } + + #[test] + fn quorum_edge_cases() { + // When we pass absolute threshold (everyone else voting no, + // we pass), but still don't hit quorum. + let quorum = Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(60)), + quorum: PercentageThreshold::Percent(Decimal::percent(80)), + }; + + // Try 9 yes, 1 no (out of 15) -> 90% voter threshold, 60% + // absolute threshold, still no quorum doesn't matter if + // expired or not. + let missing_voters = Votes { + yes: Uint128::new(9), + no: Uint128::new(1), + abstain: Uint128::new(0), + }; + assert!(!check_is_passed( + quorum.clone(), + missing_voters.clone(), + Uint128::new(15), + false, + true, + false + )); + assert!(!check_is_passed( + quorum.clone(), + missing_voters, + Uint128::new(15), + true, + true, + false + )); + + // 1 less yes, 3 vetos and this passes only when expired. + let wait_til_expired = Votes { + yes: Uint128::new(8), + no: Uint128::new(4), + abstain: Uint128::new(0), + }; + assert!(!check_is_passed( + quorum.clone(), + wait_til_expired.clone(), + Uint128::new(15), + false, + true, + false + )); + assert!(check_is_passed( + quorum.clone(), + wait_til_expired, + Uint128::new(15), + true, + true, + false + )); + + // 9 yes and 3 nos passes early + let passes_early = Votes { + yes: Uint128::new(9), + no: Uint128::new(3), + abstain: Uint128::new(0), + }; + assert!(check_is_passed( + quorum.clone(), + passes_early.clone(), + Uint128::new(15), + false, + true, + false + )); + assert!(check_is_passed( + quorum, + passes_early, + Uint128::new(15), + true, + true, + false + )); + } + + #[test] + fn test_proposal_ids_advance() { + // do they advance, lets find out! + let storage = &mut mock_dependencies().storage; + let next = next_proposal_id(storage).unwrap(); + assert_eq!(next, 1); + + let now = advance_proposal_id(storage).unwrap(); + assert_eq!(now, next); + + let next = next_proposal_id(storage).unwrap(); + assert_eq!(next, 2); + + let now = advance_proposal_id(storage).unwrap(); + assert_eq!(now, next); + } +} diff --git a/contracts/proposal/dao-proposal-single/src/query.rs b/contracts/proposal/dao-proposal-single/src/query.rs new file mode 100644 index 000000000..4d0c44eef --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/query.rs @@ -0,0 +1,45 @@ +use crate::proposal::SingleChoiceProposal; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use dao_voting::voting::Vote; + +/// Information about a proposal returned by proposal queries. +#[cw_serde] +pub struct ProposalResponse { + /// The ID of the proposal being returned. + pub id: u64, + pub proposal: SingleChoiceProposal, +} + +/// Information about a vote that was cast. +#[cw_serde] +pub struct VoteInfo { + /// The address that voted. + pub voter: Addr, + /// Position on the vote. + pub vote: Vote, + /// The voting power behind the vote. + pub power: Uint128, + /// Address-specified rationale for the vote. + pub rationale: Option, +} + +/// Information about a vote. +#[cw_serde] +pub struct VoteResponse { + /// None if no such vote, Some otherwise. + pub vote: Option, +} + +/// Information about the votes for a proposal. +#[cw_serde] +pub struct VoteListResponse { + pub votes: Vec, +} + +/// A list of proposals returned by `ListProposals` and +/// `ReverseProposals`. +#[cw_serde] +pub struct ProposalListResponse { + pub proposals: Vec, +} diff --git a/contracts/proposal/dao-proposal-single/src/state.rs b/contracts/proposal/dao-proposal-single/src/state.rs new file mode 100644 index 000000000..3a130bdda --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/state.rs @@ -0,0 +1,73 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_hooks::Hooks; +use cw_storage_plus::{Item, Map}; +use cw_utils::Duration; +use dao_voting::{pre_propose::ProposalCreationPolicy, threshold::Threshold, voting::Vote}; + +use crate::proposal::SingleChoiceProposal; + +/// A vote cast for a proposal. +#[cw_serde] +pub struct Ballot { + /// The amount of voting power behind the vote. + pub power: Uint128, + /// The position. + pub vote: Vote, + + /// An optional rationale for why this vote was cast. If the key + /// is missing (i.e. the ballot was cast in a v1 proposal module), + /// we deserialize into None (i.e. Option::default()). + #[serde(default)] + pub rationale: Option, +} +/// The governance module's configuration. +#[cw_serde] +pub struct Config { + /// The threshold a proposal must reach to complete. + pub threshold: Threshold, + /// The default maximum amount of time a proposal may be voted on + /// before expiring. + pub max_voting_period: Duration, + /// The minimum amount of time a proposal must be open before + /// passing. A proposal may fail before this amount of time has + /// elapsed, but it will not pass. This can be useful for + /// preventing governance attacks wherein an attacker aquires a + /// large number of tokens and forces a proposal through. + pub min_voting_period: Option, + /// If set to true only members may execute passed + /// proposals. Otherwise, any address may execute a passed + /// proposal. + pub only_members_execute: bool, + /// Allows changing votes before the proposal expires. If this is + /// enabled proposals will not be able to complete early as final + /// vote information is not known until the time of proposal + /// expiration. + pub allow_revoting: bool, + /// The address of the DAO that this governance module is + /// associated with. + pub dao: Addr, + /// If set to true proposals will be closed if their execution + /// fails. Otherwise, proposals will remain open after execution + /// failure. For example, with this enabled a proposal to send 5 + /// tokens out of a DAO's treasury with 4 tokens would be closed when + /// it is executed. With this disabled, that same proposal would + /// remain open until the DAO's treasury was large enough for it to be + /// executed. + pub close_proposal_on_execution_failure: bool, +} + +/// The current top level config for the module. The "config" key was +/// previously used to store configs for v1 DAOs. +pub const CONFIG: Item = Item::new("config_v2"); +/// The number of proposals that have been created. +pub const PROPOSAL_COUNT: Item = Item::new("proposal_count"); +pub const PROPOSALS: Map = Map::new("proposals_v2"); +pub const BALLOTS: Map<(u64, &Addr), Ballot> = Map::new("ballots"); +/// Consumers of proposal state change hooks. +pub const PROPOSAL_HOOKS: Hooks = Hooks::new("proposal_hooks"); +/// Consumers of vote hooks. +pub const VOTE_HOOKS: Hooks = Hooks::new("vote_hooks"); +/// The address of the pre-propose module associated with this +/// proposal module (if any). +pub const CREATION_POLICY: Item = Item::new("creation_policy"); diff --git a/contracts/proposal/dao-proposal-single/src/testing/adversarial_tests.rs b/contracts/proposal/dao-proposal-single/src/testing/adversarial_tests.rs new file mode 100644 index 000000000..5d32804b9 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/adversarial_tests.rs @@ -0,0 +1,377 @@ +use crate::msg::InstantiateMsg; +use crate::testing::instantiate::get_pre_propose_info; +use crate::testing::{ + execute::{ + close_proposal, execute_proposal, execute_proposal_should_fail, make_proposal, mint_cw20s, + vote_on_proposal, + }, + instantiate::{ + get_default_token_dao_proposal_module_instantiate, + instantiate_with_staked_balances_governance, + }, + queries::{query_balance_cw20, query_dao_token, query_proposal, query_single_proposal_module}, +}; +use cosmwasm_std::{to_binary, Addr, CosmosMsg, Decimal, Uint128, WasmMsg}; +use cw20::Cw20Coin; +use cw_multi_test::{next_block, App}; +use cw_utils::Duration; +use dao_voting::{ + deposit::{DepositRefundPolicy, UncheckedDepositInfo}, + status::Status, + threshold::{PercentageThreshold, Threshold::AbsolutePercentage}, + voting::Vote, +}; + +use super::CREATOR_ADDR; +use crate::{query::ProposalResponse, ContractError}; + +struct CommonTest { + app: App, + proposal_module: Addr, + proposal_id: u64, +} +fn setup_test(messages: Vec) -> CommonTest { + let mut app = App::default(); + let instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + // Mint some tokens to pay the proposal deposit. + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, messages); + + CommonTest { + app, + proposal_module, + proposal_id, + } +} + +// A proposal that is still accepting votes (is open) cannot +// be executed. Any attempts to do so should fail and return +// an error. +#[test] +fn test_execute_proposal_open() { + let CommonTest { + mut app, + proposal_module, + proposal_id, + } = setup_test(vec![]); + + app.update_block(next_block); + + // assert proposal is open + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Open); + + // attempt to execute and assert that it fails + let err = execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::NotPassed {})) +} + +// A proposal can be executed if and only if it passed. +// Any attempts to execute a proposal that has been rejected +// or closed (after rejection) should fail and return an error. +#[test] +fn test_execute_proposal_rejected_closed() { + let CommonTest { + mut app, + proposal_module, + proposal_id, + } = setup_test(vec![]); + + // Assert proposal is open and vote enough to reject it + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Open); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::No, + ); + + app.update_block(next_block); + + // Assert proposal is rejected + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Rejected); + + // Attempt to execute + let err = execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::NotPassed {})); + + app.update_block(next_block); + + // close the proposal + close_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Closed); + + // Attempt to execute + let err = execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::NotPassed {})) +} + +// A proposal can only be executed once. Any subsequent +// attempts to execute it should fail and return an error. +#[test] +fn test_execute_proposal_more_than_once() { + let CommonTest { + mut app, + proposal_module, + proposal_id, + } = setup_test(vec![]); + + // Assert proposal is open and vote enough to reject it + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Open); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + + app.update_block(next_block); + + // assert proposal is passed, execute it + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + app.update_block(next_block); + + // assert proposal executed and attempt to execute it again + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); + let err: ContractError = + execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::NotPassed {})); +} + +// After proposal is executed, no subsequent votes +// should change the status of the proposal, even if +// the votes should shift to the opposing direction. +#[test] +pub fn test_executed_prop_state_remains_after_vote_swing() { + let mut app = App::default(); + + let instantiate = InstantiateMsg { + threshold: AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(15)), + }, + max_voting_period: Duration::Time(604800), // One week. + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(10_000_000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + close_proposal_on_execution_failure: true, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "threshold".to_string(), + amount: Uint128::new(20), + }, + Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(50), + }, + Cw20Coin { + address: "overslept_vote".to_string(), + amount: Uint128::new(30), + }, + ]), + ); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + // someone quickly votes, proposal gets executed + vote_on_proposal( + &mut app, + &proposal_module, + "threshold", + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + app.update_block(next_block); + + // assert prop is executed prior to its expiry + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert!(!proposal.proposal.expiration.is_expired(&app.block_info())); + + // someone wakes up and casts their vote to express their + // opinion (not affecting the result of proposal) + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::No, + ); + vote_on_proposal( + &mut app, + &proposal_module, + "overslept_vote", + proposal_id, + Vote::No, + ); + + app.update_block(next_block); + + // assert that everyone's votes are reflected in the proposal + // and proposal remains in executed state + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert_eq!(proposal.proposal.votes.no, Uint128::new(80)); +} + +// After reaching a passing state, no subsequent votes +// should change the status of the proposal, even if +// the votes should shift to the opposing direction. +#[test] +pub fn test_passed_prop_state_remains_after_vote_swing() { + let mut app = App::default(); + + let instantiate = InstantiateMsg { + threshold: AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(15)), + }, + max_voting_period: Duration::Time(604800), // One week. + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(10_000_000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + close_proposal_on_execution_failure: true, + }; + + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "threshold".to_string(), + amount: Uint128::new(20), + }, + Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(50), + }, + Cw20Coin { + address: "overslept_vote".to_string(), + amount: Uint128::new(30), + }, + ]), + ); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + // if the proposal passes, it should mint 100_000_000 tokens to "threshold" + let msg = cw20::Cw20ExecuteMsg::Mint { + recipient: "threshold".to_string(), + amount: Uint128::new(100_000_000), + }; + let binary_msg = to_binary(&msg).unwrap(); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + vec![WasmMsg::Execute { + contract_addr: gov_token.to_string(), + msg: binary_msg, + funds: vec![], + } + .into()], + ); + + // assert that the initial "threshold" address balance is 0 + let balance = query_balance_cw20(&app, gov_token.to_string(), "threshold"); + assert_eq!(balance, Uint128::zero()); + + // vote enough to pass the proposal + vote_on_proposal( + &mut app, + &proposal_module, + "threshold", + proposal_id, + Vote::Yes, + ); + + // assert proposal is passed with 20 votes in favor and none opposed + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert_eq!(proposal.proposal.votes.no, Uint128::zero()); + + app.update_block(next_block); + + // the other voters wake up, vote against the proposal + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::No, + ); + vote_on_proposal( + &mut app, + &proposal_module, + "overslept_vote", + proposal_id, + Vote::No, + ); + + app.update_block(next_block); + + // assert that the late votes have been counted and proposal + // is still in passed state before executing it + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert_eq!(proposal.proposal.votes.no, Uint128::new(80)); + + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + app.update_block(next_block); + + // make sure that the initial "threshold" address balance is + // 100_000_000 and late votes did not make a difference + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); + assert_eq!(proposal.proposal.votes.yes, Uint128::new(20)); + assert_eq!(proposal.proposal.votes.no, Uint128::new(80)); + let balance = query_balance_cw20(&app, gov_token.to_string(), "threshold"); + assert_eq!(balance, Uint128::new(100_000_000)); +} diff --git a/contracts/proposal/dao-proposal-single/src/testing/contracts.rs b/contracts/proposal/dao-proposal-single/src/testing/contracts.rs new file mode 100644 index 000000000..f27bd7e35 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/contracts.rs @@ -0,0 +1,119 @@ +use cosmwasm_std::Empty; + +use cw_multi_test::{Contract, ContractWrapper}; +use dao_pre_propose_single as cppbps; + +pub(crate) fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +pub(crate) fn cw4_group_contract() -> Box> { + let contract = ContractWrapper::new( + cw4_group::contract::execute, + cw4_group::contract::instantiate, + cw4_group::contract::query, + ); + Box::new(contract) +} + +pub(crate) fn cw721_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw721_base::entry::execute, + cw721_base::entry::instantiate, + cw721_base::entry::query, + ); + Box::new(contract) +} + +pub(crate) fn cw20_stake_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_stake::contract::execute, + cw20_stake::contract::instantiate, + cw20_stake::contract::query, + ); + Box::new(contract) +} + +pub(crate) fn v1_proposal_single_contract() -> Box> { + let contract = ContractWrapper::new( + cw_proposal_single_v1::contract::execute, + cw_proposal_single_v1::contract::instantiate, + cw_proposal_single_v1::contract::query, + ) + .with_reply(cw_proposal_single_v1::contract::reply) + .with_migrate(cw_proposal_single_v1::contract::migrate); + Box::new(contract) +} + +pub(crate) fn proposal_single_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +pub(crate) fn pre_propose_single_contract() -> Box> { + let contract = ContractWrapper::new( + cppbps::contract::execute, + cppbps::contract::instantiate, + cppbps::contract::query, + ); + Box::new(contract) +} + +pub(crate) fn cw20_staked_balances_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw20_staked::contract::execute, + dao_voting_cw20_staked::contract::instantiate, + dao_voting_cw20_staked::contract::query, + ) + .with_reply(dao_voting_cw20_staked::contract::reply); + Box::new(contract) +} + +pub(crate) fn native_staked_balances_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_native_staked::contract::execute, + dao_voting_native_staked::contract::instantiate, + dao_voting_native_staked::contract::query, + ); + Box::new(contract) +} + +pub(crate) fn cw721_stake_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw721_staked::contract::execute, + dao_voting_cw721_staked::contract::instantiate, + dao_voting_cw721_staked::contract::query, + ); + Box::new(contract) +} + +pub(crate) fn cw_core_contract() -> Box> { + let contract = ContractWrapper::new( + dao_dao_core::contract::execute, + dao_dao_core::contract::instantiate, + dao_dao_core::contract::query, + ) + .with_reply(dao_dao_core::contract::reply); + Box::new(contract) +} + +pub(crate) fn cw4_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw4::contract::execute, + dao_voting_cw4::contract::instantiate, + dao_voting_cw4::contract::query, + ) + .with_reply(dao_voting_cw4::contract::reply); + Box::new(contract) +} diff --git a/contracts/proposal/dao-proposal-single/src/testing/do_votes.rs b/contracts/proposal/dao-proposal-single/src/testing/do_votes.rs new file mode 100644 index 000000000..ecd72e325 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/do_votes.rs @@ -0,0 +1,381 @@ +use cosmwasm_std::{coins, Addr, Uint128}; +use cw20::Cw20Coin; + +use cw_multi_test::{App, BankSudo, Executor}; +use dao_interface::state::ProposalModule; +use dao_pre_propose_single as cppbps; + +use cw_denom::CheckedDenom; +use dao_testing::{ShouldExecute, TestSingleChoiceVote}; +use dao_voting::{ + deposit::{CheckedDepositInfo, UncheckedDepositInfo}, + status::Status, + threshold::Threshold, +}; + +use crate::{ + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + query::{ProposalResponse, VoteInfo, VoteResponse}, + testing::{instantiate::*, queries::query_deposit_config_and_pre_propose_module}, +}; + +pub(crate) fn do_votes_staked_balances( + votes: Vec, + threshold: Threshold, + expected_status: Status, + total_supply: Option, +) { + do_test_votes( + votes, + threshold, + expected_status, + total_supply, + None::, + instantiate_with_staked_balances_governance, + ); +} + +pub(crate) fn do_votes_nft_balances( + votes: Vec, + threshold: Threshold, + expected_status: Status, + total_supply: Option, +) { + do_test_votes( + votes, + threshold, + expected_status, + total_supply, + None, + instantiate_with_staked_cw721_governance, + ); +} + +pub(crate) fn do_votes_native_staked_balances( + votes: Vec, + threshold: Threshold, + expected_status: Status, + total_supply: Option, +) { + do_test_votes( + votes, + threshold, + expected_status, + total_supply, + None, + instantiate_with_native_staked_balances_governance, + ); +} + +pub(crate) fn do_votes_cw4_weights( + votes: Vec, + threshold: Threshold, + expected_status: Status, + total_supply: Option, +) { + do_test_votes( + votes, + threshold, + expected_status, + total_supply, + None::, + instantiate_with_cw4_groups_governance, + ); +} + +fn do_test_votes( + votes: Vec, + threshold: Threshold, + expected_status: Status, + total_supply: Option, + deposit_info: Option, + setup_governance: F, +) -> (App, Addr) +where + F: Fn(&mut App, InstantiateMsg, Option>) -> Addr, +{ + let mut app = App::default(); + + let mut initial_balances = votes + .iter() + .map(|TestSingleChoiceVote { voter, weight, .. }| Cw20Coin { + address: voter.to_string(), + amount: *weight, + }) + .collect::>(); + let initial_balances_supply = votes.iter().fold(Uint128::zero(), |p, n| p + n.weight); + let to_fill = total_supply.map(|total_supply| total_supply - initial_balances_supply); + if let Some(fill) = to_fill { + initial_balances.push(Cw20Coin { + address: "filler".to_string(), + amount: fill, + }) + } + + let pre_propose_info = get_pre_propose_info(&mut app, deposit_info, false); + + let proposer = match votes.first() { + Some(vote) => vote.voter.clone(), + None => panic!("do_test_votes must have at least one vote."), + }; + + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = InstantiateMsg { + threshold, + max_voting_period, + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + close_proposal_on_execution_failure: true, + pre_propose_info, + }; + + let core_addr = setup_governance(&mut app, instantiate, Some(initial_balances)); + + let governance_modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(governance_modules.len(), 1); + let proposal_single = governance_modules.into_iter().next().unwrap().address; + + let (deposit_config, pre_propose_module) = + query_deposit_config_and_pre_propose_module(&app, &proposal_single); + // Pay the cw20 deposit if needed. + if let Some(CheckedDepositInfo { + denom: CheckedDenom::Cw20(ref token), + amount, + .. + }) = deposit_config.deposit_info + { + app.execute_contract( + Addr::unchecked(&proposer), + token.clone(), + &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + spender: pre_propose_module.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); + } + + let funds = if let Some(CheckedDepositInfo { + denom: CheckedDenom::Native(ref denom), + amount, + .. + }) = deposit_config.deposit_info + { + // Mint the needed tokens to create the deposit. + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: proposer.clone(), + amount: coins(amount.u128(), denom), + })) + .unwrap(); + coins(amount.u128(), denom) + } else { + vec![] + }; + + app.execute_contract( + Addr::unchecked(&proposer), + pre_propose_module, + &cppbps::ExecuteMsg::Propose { + msg: cppbps::ProposeMessage::Propose { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + msgs: vec![], + }, + }, + &funds, + ) + .unwrap(); + + // Cast votes. + for vote in votes { + let TestSingleChoiceVote { + voter, + position, + weight, + should_execute, + } = vote; + // Vote on the proposal. + let res = app.execute_contract( + Addr::unchecked(voter.clone()), + proposal_single.clone(), + &ExecuteMsg::Vote { + proposal_id: 1, + vote: position, + rationale: None, + }, + &[], + ); + match should_execute { + ShouldExecute::Yes => { + assert!(res.is_ok()); + // Check that the vote was recorded correctly. + let vote: VoteResponse = app + .wrap() + .query_wasm_smart( + proposal_single.clone(), + &QueryMsg::GetVote { + proposal_id: 1, + voter: voter.clone(), + }, + ) + .unwrap(); + let expected = VoteResponse { + vote: Some(VoteInfo { + rationale: None, + voter: Addr::unchecked(&voter), + vote: position, + power: match deposit_config.deposit_info { + Some(CheckedDepositInfo { + amount, + denom: CheckedDenom::Cw20(_), + .. + }) => { + if proposer == voter { + weight - amount + } else { + weight + } + } + // Native token deposits shouldn't impact + // expected voting power. + _ => weight, + }, + }), + }; + assert_eq!(vote, expected) + } + ShouldExecute::No => { + res.unwrap_err(); + } + ShouldExecute::Meh => (), + } + } + + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart(proposal_single, &QueryMsg::Proposal { proposal_id: 1 }) + .unwrap(); + + assert_eq!(proposal.proposal.status, expected_status); + + (app, core_addr) +} + +#[test] +fn test_vote_simple() { + dao_testing::test_simple_votes(do_votes_cw4_weights); + dao_testing::test_simple_votes(do_votes_staked_balances); + dao_testing::test_simple_votes(do_votes_nft_balances); + dao_testing::test_simple_votes(do_votes_native_staked_balances) +} + +#[test] +fn test_simple_vote_no_overflow() { + dao_testing::test_simple_vote_no_overflow(do_votes_staked_balances); + dao_testing::test_simple_vote_no_overflow(do_votes_native_staked_balances); +} + +#[test] +fn test_vote_no_overflow() { + dao_testing::test_vote_no_overflow(do_votes_staked_balances); + dao_testing::test_vote_no_overflow(do_votes_native_staked_balances); +} + +#[test] +fn test_simple_early_rejection() { + dao_testing::test_simple_early_rejection(do_votes_cw4_weights); + dao_testing::test_simple_early_rejection(do_votes_staked_balances); + dao_testing::test_simple_early_rejection(do_votes_native_staked_balances); +} + +#[test] +fn test_vote_abstain_only() { + dao_testing::test_vote_abstain_only(do_votes_cw4_weights); + dao_testing::test_vote_abstain_only(do_votes_staked_balances); + dao_testing::test_vote_abstain_only(do_votes_native_staked_balances); +} + +#[test] +fn test_tricky_rounding() { + dao_testing::test_tricky_rounding(do_votes_cw4_weights); + dao_testing::test_tricky_rounding(do_votes_staked_balances); + dao_testing::test_tricky_rounding(do_votes_native_staked_balances); +} + +#[test] +fn test_no_double_votes() { + dao_testing::test_no_double_votes(do_votes_cw4_weights); + dao_testing::test_no_double_votes(do_votes_staked_balances); + dao_testing::test_no_double_votes(do_votes_nft_balances); + dao_testing::test_no_double_votes(do_votes_native_staked_balances); +} + +#[test] +fn test_votes_favor_yes() { + dao_testing::test_votes_favor_yes(do_votes_staked_balances); + dao_testing::test_votes_favor_yes(do_votes_nft_balances); + dao_testing::test_votes_favor_yes(do_votes_native_staked_balances); +} + +#[test] +fn test_votes_low_threshold() { + dao_testing::test_votes_low_threshold(do_votes_cw4_weights); + dao_testing::test_votes_low_threshold(do_votes_staked_balances); + dao_testing::test_votes_low_threshold(do_votes_nft_balances); + dao_testing::test_votes_low_threshold(do_votes_native_staked_balances); +} + +#[test] +fn test_majority_vs_half() { + dao_testing::test_majority_vs_half(do_votes_cw4_weights); + dao_testing::test_majority_vs_half(do_votes_staked_balances); + dao_testing::test_majority_vs_half(do_votes_nft_balances); + dao_testing::test_majority_vs_half(do_votes_native_staked_balances); +} + +#[test] +fn test_pass_threshold_not_quorum() { + dao_testing::test_pass_threshold_not_quorum(do_votes_cw4_weights); + dao_testing::test_pass_threshold_not_quorum(do_votes_staked_balances); + dao_testing::test_pass_threshold_not_quorum(do_votes_nft_balances); + dao_testing::test_pass_threshold_not_quorum(do_votes_native_staked_balances); +} + +#[test] +fn test_pass_threshold_exactly_quorum() { + dao_testing::test_pass_exactly_quorum(do_votes_cw4_weights); + dao_testing::test_pass_exactly_quorum(do_votes_staked_balances); + dao_testing::test_pass_exactly_quorum(do_votes_nft_balances); + dao_testing::test_pass_exactly_quorum(do_votes_native_staked_balances); +} + +/// Generate some random voting selections and make sure they behave +/// as expected. We split this test up as these take a while and cargo +/// can parallize tests. +#[test] +fn fuzz_voting_cw4_weights() { + dao_testing::fuzz_voting(do_votes_cw4_weights) +} + +#[test] +fn fuzz_voting_staked_balances() { + dao_testing::fuzz_voting(do_votes_staked_balances) +} + +#[test] +fn fuzz_voting_native_staked_balances() { + dao_testing::fuzz_voting(do_votes_native_staked_balances) +} diff --git a/contracts/proposal/dao-proposal-single/src/testing/execute.rs b/contracts/proposal/dao-proposal-single/src/testing/execute.rs new file mode 100644 index 000000000..657d22874 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/execute.rs @@ -0,0 +1,451 @@ +use cosmwasm_std::{coins, Addr, Coin, CosmosMsg, Uint128}; +use cw_multi_test::{App, BankSudo, Executor}; + +use cw_denom::CheckedDenom; +use dao_pre_propose_single as cppbps; +use dao_voting::{ + deposit::CheckedDepositInfo, pre_propose::ProposalCreationPolicy, + proposal::SingleChoiceProposeMsg as ProposeMsg, voting::Vote, +}; + +use crate::{ + msg::{ExecuteMsg, QueryMsg}, + query::ProposalResponse, + testing::queries::{query_creation_policy, query_next_proposal_id}, + ContractError, +}; + +use super::{ + contracts::cw20_base_contract, queries::query_pre_proposal_single_config, CREATOR_ADDR, +}; + +// Creates a proposal then checks that the proposal was created with +// the specified messages and returns the ID of the proposal. +// +// This expects that the proposer already has the needed tokens to pay +// the deposit. +pub(crate) fn make_proposal( + app: &mut App, + proposal_single: &Addr, + proposer: &str, + msgs: Vec, +) -> u64 { + let proposal_creation_policy = query_creation_policy(app, proposal_single); + + // Collect the funding. + let funds = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => vec![], + ProposalCreationPolicy::Module { + addr: ref pre_propose, + } => { + let deposit_config = query_pre_proposal_single_config(app, pre_propose); + match deposit_config.deposit_info { + Some(CheckedDepositInfo { + denom, + amount, + refund_policy: _, + }) => match denom { + CheckedDenom::Native(denom) => coins(amount.u128(), denom), + CheckedDenom::Cw20(addr) => { + // Give an allowance, no funds. + app.execute_contract( + Addr::unchecked(proposer), + addr, + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: pre_propose.to_string(), + amount, + expires: None, + }, + &[], + ) + .unwrap(); + vec![] + } + }, + None => vec![], + } + } + }; + + // Make the proposal. + match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => app + .execute_contract( + Addr::unchecked(proposer), + proposal_single.clone(), + &ExecuteMsg::Propose(ProposeMsg { + title: "title".to_string(), + description: "description".to_string(), + msgs: msgs.clone(), + proposer: None, + }), + &[], + ) + .unwrap(), + ProposalCreationPolicy::Module { addr } => app + .execute_contract( + Addr::unchecked(proposer), + addr, + &cppbps::ExecuteMsg::Propose { + msg: cppbps::ProposeMessage::Propose { + title: "title".to_string(), + description: "description".to_string(), + msgs: msgs.clone(), + }, + }, + &funds, + ) + .unwrap(), + }; + let id = query_next_proposal_id(app, proposal_single); + let id = id - 1; + + // Check that the proposal was created as expected. + let proposal: ProposalResponse = app + .wrap() + .query_wasm_smart(proposal_single, &QueryMsg::Proposal { proposal_id: id }) + .unwrap(); + + assert_eq!(proposal.proposal.proposer, Addr::unchecked(proposer)); + assert_eq!(proposal.proposal.title, "title".to_string()); + assert_eq!(proposal.proposal.description, "description".to_string()); + assert_eq!(proposal.proposal.msgs, msgs); + + id +} + +pub(crate) fn vote_on_proposal( + app: &mut App, + proposal_single: &Addr, + sender: &str, + proposal_id: u64, + vote: Vote, +) { + app.execute_contract( + Addr::unchecked(sender), + proposal_single.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + .unwrap(); +} + +pub(crate) fn vote_on_proposal_should_fail( + app: &mut App, + proposal_single: &Addr, + sender: &str, + proposal_id: u64, + vote: Vote, +) -> ContractError { + app.execute_contract( + Addr::unchecked(sender), + proposal_single.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +pub(crate) fn execute_proposal_should_fail( + app: &mut App, + proposal_single: &Addr, + sender: &str, + proposal_id: u64, +) -> ContractError { + app.execute_contract( + Addr::unchecked(sender), + proposal_single.clone(), + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +pub(crate) fn vote_on_proposal_with_rationale( + app: &mut App, + proposal_single: &Addr, + sender: &str, + proposal_id: u64, + vote: Vote, + rationale: Option, +) { + app.execute_contract( + Addr::unchecked(sender), + proposal_single.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote, + rationale, + }, + &[], + ) + .unwrap(); +} + +pub(crate) fn update_rationale( + app: &mut App, + proposal_single: &Addr, + sender: &str, + proposal_id: u64, + rationale: Option, +) { + app.execute_contract( + Addr::unchecked(sender), + proposal_single.clone(), + &ExecuteMsg::UpdateRationale { + proposal_id, + rationale, + }, + &[], + ) + .unwrap(); +} + +pub(crate) fn execute_proposal( + app: &mut App, + proposal_single: &Addr, + sender: &str, + proposal_id: u64, +) { + app.execute_contract( + Addr::unchecked(sender), + proposal_single.clone(), + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); +} + +pub(crate) fn close_proposal_should_fail( + app: &mut App, + proposal_single: &Addr, + sender: &str, + proposal_id: u64, +) -> ContractError { + app.execute_contract( + Addr::unchecked(sender), + proposal_single.clone(), + &ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +pub(crate) fn close_proposal( + app: &mut App, + proposal_single: &Addr, + sender: &str, + proposal_id: u64, +) { + app.execute_contract( + Addr::unchecked(sender), + proposal_single.clone(), + &ExecuteMsg::Close { proposal_id }, + &[], + ) + .unwrap(); +} + +pub(crate) fn mint_natives(app: &mut App, receiver: &str, amount: Vec) { + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: receiver.to_string(), + amount, + })) + .unwrap(); +} + +pub(crate) fn mint_cw20s( + app: &mut App, + cw20_contract: &Addr, + sender: &Addr, + receiver: &str, + amount: u128, +) { + app.execute_contract( + sender.clone(), + cw20_contract.clone(), + &cw20::Cw20ExecuteMsg::Mint { + recipient: receiver.to_string(), + amount: Uint128::new(amount), + }, + &[], + ) + .unwrap(); +} + +pub(crate) fn instantiate_cw20_base_default(app: &mut App) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_instantiate = cw20_base::msg::InstantiateMsg { + name: "cw20 token".to_string(), + symbol: "cwtwenty".to_string(), + decimals: 6, + initial_balances: vec![cw20::Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(10_000_000), + }], + mint: None, + marketing: None, + }; + app.instantiate_contract( + cw20_id, + Addr::unchecked("ekez"), + &cw20_instantiate, + &[], + "cw20-base", + None, + ) + .unwrap() +} + +pub(crate) fn add_proposal_hook( + app: &mut App, + proposal_module: &Addr, + sender: &str, + hook_addr: &str, +) { + app.execute_contract( + Addr::unchecked(sender), + proposal_module.clone(), + &ExecuteMsg::AddProposalHook { + address: hook_addr.to_string(), + }, + &[], + ) + .unwrap(); +} + +pub(crate) fn add_proposal_hook_should_fail( + app: &mut App, + proposal_module: &Addr, + sender: &str, + hook_addr: &str, +) -> ContractError { + app.execute_contract( + Addr::unchecked(sender), + proposal_module.clone(), + &ExecuteMsg::AddProposalHook { + address: hook_addr.to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +pub(crate) fn remove_proposal_hook( + app: &mut App, + proposal_module: &Addr, + sender: &str, + hook_addr: &str, +) { + app.execute_contract( + Addr::unchecked(sender), + proposal_module.clone(), + &ExecuteMsg::RemoveProposalHook { + address: hook_addr.to_string(), + }, + &[], + ) + .unwrap(); +} + +pub(crate) fn remove_proposal_hook_should_fail( + app: &mut App, + proposal_module: &Addr, + sender: &str, + hook_addr: &str, +) -> ContractError { + app.execute_contract( + Addr::unchecked(sender), + proposal_module.clone(), + &ExecuteMsg::RemoveProposalHook { + address: hook_addr.to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +pub(crate) fn add_vote_hook(app: &mut App, proposal_module: &Addr, sender: &str, hook_addr: &str) { + app.execute_contract( + Addr::unchecked(sender), + proposal_module.clone(), + &ExecuteMsg::AddVoteHook { + address: hook_addr.to_string(), + }, + &[], + ) + .unwrap(); +} + +pub(crate) fn add_vote_hook_should_fail( + app: &mut App, + proposal_module: &Addr, + sender: &str, + hook_addr: &str, +) -> ContractError { + app.execute_contract( + Addr::unchecked(sender), + proposal_module.clone(), + &ExecuteMsg::AddVoteHook { + address: hook_addr.to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} + +pub(crate) fn remove_vote_hook( + app: &mut App, + proposal_module: &Addr, + sender: &str, + hook_addr: &str, +) { + app.execute_contract( + Addr::unchecked(sender), + proposal_module.clone(), + &ExecuteMsg::RemoveVoteHook { + address: hook_addr.to_string(), + }, + &[], + ) + .unwrap(); +} + +pub(crate) fn remove_vote_hook_should_fail( + app: &mut App, + proposal_module: &Addr, + sender: &str, + hook_addr: &str, +) -> ContractError { + app.execute_contract( + Addr::unchecked(sender), + proposal_module.clone(), + &ExecuteMsg::RemoveVoteHook { + address: hook_addr.to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap() +} diff --git a/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs b/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs new file mode 100644 index 000000000..7f1353c0c --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs @@ -0,0 +1,610 @@ +use cosmwasm_std::{to_binary, Addr, Coin, Decimal, Empty, Uint128}; +use cw20::Cw20Coin; + +use cw_multi_test::{next_block, App, BankSudo, Executor, SudoMsg}; +use cw_utils::Duration; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_pre_propose_single as cppbps; + +use dao_voting::{ + deposit::{DepositRefundPolicy, UncheckedDepositInfo}, + pre_propose::PreProposeInfo, + threshold::{ActiveThreshold, PercentageThreshold, Threshold::ThresholdQuorum}, +}; +use dao_voting_cw4::msg::GroupContract; + +use crate::msg::InstantiateMsg; + +use super::{ + contracts::{ + cw20_base_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, + cw4_group_contract, cw4_voting_contract, cw721_base_contract, cw721_stake_contract, + cw_core_contract, native_staked_balances_voting_contract, proposal_single_contract, + }, + CREATOR_ADDR, +}; + +pub(crate) fn get_pre_propose_info( + app: &mut App, + deposit_info: Option, + open_proposal_submission: bool, +) -> PreProposeInfo { + let pre_propose_contract = + app.store_code(crate::testing::contracts::pre_propose_single_contract()); + PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_contract, + msg: to_binary(&cppbps::InstantiateMsg { + deposit_info, + open_proposal_submission, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "pre_propose_contract".to_string(), + }, + } +} + +pub(crate) fn get_default_token_dao_proposal_module_instantiate(app: &mut App) -> InstantiateMsg { + InstantiateMsg { + threshold: ThresholdQuorum { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + threshold: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(604800), // One week. + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info( + app, + Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(10_000_000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + false, + ), + close_proposal_on_execution_failure: true, + } +} + +// Same as above but no proposal deposit. +pub(crate) fn get_default_non_token_dao_proposal_module_instantiate( + app: &mut App, +) -> InstantiateMsg { + InstantiateMsg { + threshold: ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(15)), + quorum: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(604800), // One week. + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info: get_pre_propose_info(app, None, false), + close_proposal_on_execution_failure: true, + } +} + +pub(crate) fn instantiate_with_staked_cw721_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_single_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let cw721_id = app.store_code(cw721_base_contract()); + let cw721_stake_id = app.store_code(cw721_stake_contract()); + let core_contract_id = app.store_code(cw_core_contract()); + + let nft_address = app + .instantiate_contract( + cw721_id, + Addr::unchecked("ekez"), + &cw721_base::msg::InstantiateMsg { + minter: "ekez".to_string(), + symbol: "token".to_string(), + name: "ekez token best token".to_string(), + }, + &[], + "nft-staking", + None, + ) + .unwrap(); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + dao_uri: None, + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: cw721_stake_id, + msg: to_binary(&dao_voting_cw721_staked::msg::InstantiateMsg { + owner: Some(Admin::CoreModule {}), + unstaking_duration: None, + nft_contract: dao_voting_cw721_staked::msg::NftContract::Existing { + address: nft_address.to_string(), + }, + active_threshold: None, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&proposal_module_instantiate).unwrap(), + }], + initial_items: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let core_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let staking_addr = core_state.voting_module; + + for Cw20Coin { address, amount } in initial_balances { + for i in 0..amount.u128() { + app.execute_contract( + Addr::unchecked("ekez"), + nft_address.clone(), + &cw721_base::msg::ExecuteMsg::, Empty>::Mint { + token_id: format!("{address}_{i}"), + owner: address.clone(), + token_uri: None, + extension: None, + }, + &[], + ) + .unwrap(); + app.execute_contract( + Addr::unchecked(address.clone()), + nft_address.clone(), + &cw721_base::msg::ExecuteMsg::SendNft::, Empty> { + contract: staking_addr.to_string(), + token_id: format!("{address}_{i}"), + msg: to_binary("").unwrap(), + }, + &[], + ) + .unwrap(); + } + } + + // Update the block so that staked balances appear. + app.update_block(|block| block.height += 1); + + core_addr +} + +pub(crate) fn instantiate_with_native_staked_balances_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_single_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + // Collapse balances so that we can test double votes. + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let native_stake_id = app.store_code(native_staked_balances_voting_contract()); + let core_contract_id = app.store_code(cw_core_contract()); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + dao_uri: None, + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: native_stake_id, + msg: to_binary(&dao_voting_native_staked::msg::InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: None, + denom: "ujuno".to_string(), + unstaking_duration: None, + active_threshold: None, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&proposal_module_instantiate).unwrap(), + }], + initial_items: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let native_staking_addr = gov_state.voting_module; + + for Cw20Coin { address, amount } in initial_balances { + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: address.clone(), + amount: vec![Coin { + denom: "ujuno".to_string(), + amount, + }], + })) + .unwrap(); + app.execute_contract( + Addr::unchecked(&address), + native_staking_addr.clone(), + &dao_voting_native_staked::msg::ExecuteMsg::Stake {}, + &[Coin { + amount, + denom: "ujuno".to_string(), + }], + ) + .unwrap(); + } + + app.update_block(next_block); + + core_addr +} + +pub(crate) fn instantiate_with_staked_balances_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_single_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + // Collapse balances so that we can test double votes. + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_stake_id = app.store_code(cw20_stake_contract()); + let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); + let core_contract_id = app.store_code(cw_core_contract()); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + dao_uri: None, + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: staked_balances_voting_id, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: None, + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token.".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: initial_balances.clone(), + marketing: None, + staking_code_id: cw20_stake_id, + unstaking_duration: Some(Duration::Height(6)), + initial_dao_balance: None, + }, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&proposal_module_instantiate).unwrap(), + }], + initial_items: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let voting_module = gov_state.voting_module; + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Stake all the initial balances. + for Cw20Coin { address, amount } in initial_balances { + app.execute_contract( + Addr::unchecked(address), + token_contract.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount, + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + } + + // Update the block so that those staked balances appear. + app.update_block(|block| block.height += 1); + + core_addr +} + +pub(crate) fn instantiate_with_staking_active_threshold( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_balances: Option>, + active_threshold: Option, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_single_contract()); + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_staking_id = app.store_code(cw20_stake_contract()); + let core_id = app.store_code(cw_core_contract()); + let votemod_id = app.store_code(cw20_staked_balances_voting_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + dao_uri: None, + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances, + marketing: None, + staking_code_id: cw20_staking_id, + unstaking_duration: None, + initial_dao_balance: None, + }, + active_threshold, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + msg: to_binary(&proposal_module_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + app.instantiate_contract( + core_id, + Addr::unchecked(CREATOR_ADDR), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap() +} + +pub(crate) fn instantiate_with_cw4_groups_governance( + app: &mut App, + proposal_module_instantiate: InstantiateMsg, + initial_weights: Option>, +) -> Addr { + let proposal_module_code_id = app.store_code(proposal_single_contract()); + let cw4_id = app.store_code(cw4_group_contract()); + let core_id = app.store_code(cw_core_contract()); + let votemod_id = app.store_code(cw4_voting_contract()); + + let initial_weights = initial_weights.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(1), + }] + }); + + // Remove duplicates so that we can test duplicate voting. + let initial_weights: Vec = { + let mut already_seen = vec![]; + initial_weights + .into_iter() + .filter(|Cw20Coin { address, .. }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .map(|Cw20Coin { address, amount }| cw4::Member { + addr: address, + weight: amount.u128() as u64, + }) + .collect() + }; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + dao_uri: None, + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw4::msg::InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members: initial_weights, + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: proposal_module_code_id, + msg: to_binary(&proposal_module_instantiate).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + let addr = app + .instantiate_contract( + core_id, + Addr::unchecked(CREATOR_ADDR), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + // Update the block so that weights appear. + app.update_block(|block| block.height += 1); + + addr +} diff --git a/contracts/proposal/dao-proposal-single/src/testing/migration_tests.rs b/contracts/proposal/dao-proposal-single/src/testing/migration_tests.rs new file mode 100644 index 000000000..1e3aa7c0c --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/migration_tests.rs @@ -0,0 +1,444 @@ +use cosmwasm_std::{to_binary, Addr, Uint128, WasmMsg}; +use cw20::Cw20Coin; +use cw_multi_test::{next_block, App, Executor}; +use dao_interface::query::{GetItemResponse, ProposalModuleCountResponse}; +use dao_testing::contracts::{ + cw20_base_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, + dao_dao_contract, proposal_single_contract, v1_dao_dao_contract, v1_proposal_single_contract, +}; +use dao_voting::{deposit::UncheckedDepositInfo, status::Status}; + +use crate::testing::{ + execute::{execute_proposal, make_proposal, vote_on_proposal}, + instantiate::get_pre_propose_info, + queries::{query_proposal, query_proposal_count}, +}; + +/// This test attempts to simulate a realistic migration from DAO DAO +/// v1 to v2. Other tests in `/tests/tests.rs` check that versions and +/// top-level configs are updated correctly during migration. This +/// concerns itself more with more subtle state in the contracts that +/// is less functionality critical and thus more likely to be +/// overlooked in migration logic. +/// +/// - I can migrate with tokens in the treasury and completed +/// proposals. +/// +/// - I can migrate an open and unexecutable proposal, and use +/// `close_proposal_on_execution_failure` to close it once the +/// migration completes. +/// +/// - Proposal count remains accurate after proposal migration. +/// +/// - Items are not overriden during migration. +#[test] +fn test_v1_v2_full_migration() { + let sender = Addr::unchecked("sender"); + + let mut app = App::default(); + + // ---- + // instantiate a v1 DAO + // ---- + + let proposal_code = app.store_code(v1_proposal_single_contract()); + let core_code = app.store_code(v1_dao_dao_contract()); + + // cw20 staking and voting module has not changed across v1->v2 so + // we use the current edition. + let cw20_code = app.store_code(cw20_base_contract()); + let cw20_stake_code = app.store_code(cw20_stake_contract()); + let voting_code = app.store_code(cw20_staked_balances_voting_contract()); + + let initial_balances = vec![Cw20Coin { + address: sender.to_string(), + amount: Uint128::new(2), + }]; + + let core = app + .instantiate_contract( + core_code, + sender.clone(), + &cw_core_v1::msg::InstantiateMsg { + admin: Some(sender.to_string()), + name: "n".to_string(), + description: "d".to_string(), + image_url: Some("i".to_string()), + automatically_add_cw20s: false, + automatically_add_cw721s: true, + voting_module_instantiate_info: cw_core_v1::msg::ModuleInstantiateInfo { + code_id: voting_code, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: None, + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_code, + label: "token".to_string(), + name: "name".to_string(), + symbol: "symbol".to_string(), + decimals: 6, + initial_balances, + marketing: None, + staking_code_id: cw20_stake_code, + unstaking_duration: None, + initial_dao_balance: Some(Uint128::new(100)), + }, + }) + .unwrap(), + admin: cw_core_v1::msg::Admin::CoreContract {}, + label: "voting".to_string(), + }, + proposal_modules_instantiate_info: vec![cw_core_v1::msg::ModuleInstantiateInfo { + code_id: proposal_code, + msg: to_binary(&cw_proposal_single_v1::msg::InstantiateMsg { + threshold: voting_v1::Threshold::AbsolutePercentage { + percentage: voting_v1::PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils_v1::Duration::Height(6), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + deposit_info: None, + }) + .unwrap(), + admin: cw_core_v1::msg::Admin::CoreContract {}, + label: "proposal".to_string(), + }], + initial_items: Some(vec![cw_core_v1::msg::InitialItem { + key: "key".to_string(), + value: "value".to_string(), + }]), + }, + &[], + "core", + Some(sender.to_string()), + ) + .unwrap(); + app.execute( + sender.clone(), + WasmMsg::UpdateAdmin { + contract_addr: core.to_string(), + admin: core.to_string(), + } + .into(), + ) + .unwrap(); + + // ---- + // stake tokens in the DAO + // ---- + + let token = { + let voting: Addr = app + .wrap() + .query_wasm_smart(&core, &cw_core_v1::msg::QueryMsg::VotingModule {}) + .unwrap(); + let staking: Addr = app + .wrap() + .query_wasm_smart( + &voting, + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token: Addr = app + .wrap() + .query_wasm_smart( + &voting, + &dao_voting_cw20_staked::msg::QueryMsg::TokenContract {}, + ) + .unwrap(); + app.execute_contract( + sender.clone(), + token.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: staking.into_string(), + amount: Uint128::new(1), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + app.update_block(next_block); + token + }; + + // ---- + // create a proposal and add tokens to the treasury. + // ---- + + let proposal = { + let modules: Vec = app + .wrap() + .query_wasm_smart( + &core, + &cw_core_v1::msg::QueryMsg::ProposalModules { + start_at: None, + limit: None, + }, + ) + .unwrap(); + assert!(modules.len() == 1); + modules.into_iter().next().unwrap() + }; + + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Propose { + title: "t".to_string(), + description: "d".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: core.to_string(), + msg: to_binary(&cw_core_v1::msg::ExecuteMsg::UpdateCw20List { + to_add: vec![token.to_string()], + to_remove: vec![], + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap(); + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Vote { + proposal_id: 1, + vote: voting_v1::Vote::Yes, + }, + &[], + ) + .unwrap(); + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Execute { proposal_id: 1 }, + &[], + ) + .unwrap(); + let tokens: Vec = app + .wrap() + .query_wasm_smart( + &core, + &cw_core_v1::msg::QueryMsg::Cw20Balances { + start_at: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + tokens, + vec![cw_core_v1::query::Cw20BalanceResponse { + addr: token.clone(), + balance: Uint128::new(100), + }] + ); + + // ---- + // Create a proposal that is unexecutable without close_proposal_on_execution_failure + // ---- + + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Propose { + title: "t".to_string(), + description: "d".to_string(), + msgs: vec![WasmMsg::Execute { + contract_addr: token.to_string(), + msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: sender.to_string(), + // more tokens than the DAO posseses. + amount: Uint128::new(101), + }) + .unwrap(), + funds: vec![], + } + .into()], + }, + &[], + ) + .unwrap(); + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Vote { + proposal_id: 2, + vote: voting_v1::Vote::Yes, + }, + &[], + ) + .unwrap(); + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Execute { proposal_id: 2 }, + &[], + ) + // can not be executed. + .unwrap_err(); + let cw_proposal_single_v1::query::ProposalResponse { + proposal: cw_proposal_single_v1::proposal::Proposal { status, .. }, + .. + } = app + .wrap() + .query_wasm_smart( + &proposal, + &cw_proposal_single_v1::msg::QueryMsg::Proposal { proposal_id: 2 }, + ) + .unwrap(); + assert_eq!(status, voting_v1::Status::Passed); + + // ---- + // create a proposal to migrate to v2 + // ---- + + let v2_core_code = app.store_code(dao_dao_contract()); + let v2_proposal_code = app.store_code(proposal_single_contract()); + + let pre_propose_info = get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed, + }), + false, + ); + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Propose { + title: "t".to_string(), + description: "d".to_string(), + msgs: vec![ + WasmMsg::Migrate { + contract_addr: core.to_string(), + new_code_id: v2_core_code, + msg: to_binary(&dao_interface::msg::MigrateMsg::FromV1 { + dao_uri: Some("dao-uri".to_string()), + params: None, + }) + .unwrap(), + } + .into(), + WasmMsg::Migrate { + contract_addr: proposal.to_string(), + new_code_id: v2_proposal_code, + msg: to_binary(&crate::msg::MigrateMsg::FromV1 { + close_proposal_on_execution_failure: true, + pre_propose_info, + }) + .unwrap(), + } + .into(), + ], + }, + &[], + ) + .unwrap(); + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Vote { + proposal_id: 3, + vote: voting_v1::Vote::Yes, + }, + &[], + ) + .unwrap(); + app.execute_contract( + sender.clone(), + proposal.clone(), + &cw_proposal_single_v1::msg::ExecuteMsg::Execute { proposal_id: 3 }, + &[], + ) + .unwrap(); + + // ---- + // execute proposal two. the addition of + // close_proposal_on_execution_failure ought to allow it to close. + // ---- + execute_proposal(&mut app, &proposal, sender.as_str(), 2); + let status = query_proposal(&app, &proposal, 2).proposal.status; + assert_eq!(status, Status::ExecutionFailed); + + // ---- + // check that proposal count is still three after proposal state migration. + // ---- + let count = query_proposal_count(&app, &proposal); + assert_eq!(count, 3); + + // ---- + // check that proposal module counts have been updated. + // ---- + let module_counts: ProposalModuleCountResponse = app + .wrap() + .query_wasm_smart(&core, &dao_interface::msg::QueryMsg::ProposalModuleCount {}) + .unwrap(); + assert_eq!( + module_counts, + ProposalModuleCountResponse { + active_proposal_module_count: 1, + total_proposal_module_count: 1, + } + ); + + // ---- + // check that items are not overriden in migration. + // ---- + let item: GetItemResponse = app + .wrap() + .query_wasm_smart( + &core, + &dao_interface::msg::QueryMsg::GetItem { + key: "key".to_string(), + }, + ) + .unwrap(); + assert_eq!( + item, + GetItemResponse { + item: Some("value".to_string()) + } + ); + + // ---- + // check that proposal can still be created an executed. + // ---- + make_proposal( + &mut app, + &proposal, + sender.as_str(), + vec![WasmMsg::Execute { + contract_addr: core.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::UpdateCw20List { + to_add: vec![], + to_remove: vec![token.into_string()], + }) + .unwrap(), + funds: vec![], + } + .into()], + ); + vote_on_proposal( + &mut app, + &proposal, + sender.as_str(), + 4, + dao_voting::voting::Vote::Yes, + ); + execute_proposal(&mut app, &proposal, sender.as_str(), 4); + let tokens: Vec = app + .wrap() + .query_wasm_smart( + &core, + &dao_interface::msg::QueryMsg::Cw20Balances { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert!(tokens.is_empty()) +} diff --git a/contracts/proposal/dao-proposal-single/src/testing/mod.rs b/contracts/proposal/dao-proposal-single/src/testing/mod.rs new file mode 100644 index 000000000..12c9b9af0 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/mod.rs @@ -0,0 +1,10 @@ +mod adversarial_tests; +mod contracts; +mod do_votes; +mod execute; +mod instantiate; +mod migration_tests; +mod queries; +mod tests; + +pub(crate) const CREATOR_ADDR: &str = "creator"; diff --git a/contracts/proposal/dao-proposal-single/src/testing/queries.rs b/contracts/proposal/dao-proposal-single/src/testing/queries.rs new file mode 100644 index 000000000..a246f1c64 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/queries.rs @@ -0,0 +1,214 @@ +use cosmwasm_std::{Addr, Uint128}; +use cw_multi_test::App; +use dao_interface::state::{ProposalModule, ProposalModuleStatus}; + +use cw_hooks::HooksResponse; +use dao_pre_propose_single as cppbps; +use dao_voting::pre_propose::ProposalCreationPolicy; + +use crate::{ + msg::QueryMsg, + query::{ProposalListResponse, ProposalResponse, VoteListResponse, VoteResponse}, + state::Config, +}; + +pub(crate) fn query_deposit_config_and_pre_propose_module( + app: &App, + proposal_single: &Addr, +) -> (cppbps::Config, Addr) { + let proposal_creation_policy = query_creation_policy(app, proposal_single); + + if let ProposalCreationPolicy::Module { addr: module_addr } = proposal_creation_policy { + let deposit_config = query_pre_proposal_single_config(app, &module_addr); + + (deposit_config, module_addr) + } else { + panic!("no pre-propose module.") + } +} + +pub(crate) fn query_proposal_config(app: &App, proposal_single: &Addr) -> Config { + app.wrap() + .query_wasm_smart(proposal_single, &QueryMsg::Config {}) + .unwrap() +} + +pub(crate) fn query_creation_policy(app: &App, proposal_single: &Addr) -> ProposalCreationPolicy { + app.wrap() + .query_wasm_smart(proposal_single, &QueryMsg::ProposalCreationPolicy {}) + .unwrap() +} + +pub(crate) fn query_list_proposals( + app: &App, + proposal_single: &Addr, + start_after: Option, + limit: Option, +) -> ProposalListResponse { + app.wrap() + .query_wasm_smart( + proposal_single, + &QueryMsg::ListProposals { start_after, limit }, + ) + .unwrap() +} + +pub(crate) fn query_list_votes( + app: &App, + proposal_single: &Addr, + proposal_id: u64, + start_after: Option, + limit: Option, +) -> VoteListResponse { + app.wrap() + .query_wasm_smart( + proposal_single, + &QueryMsg::ListVotes { + proposal_id, + start_after, + limit, + }, + ) + .unwrap() +} + +pub(crate) fn query_vote( + app: &App, + proposal_module: &Addr, + who: &str, + proposal_id: u64, +) -> VoteResponse { + app.wrap() + .query_wasm_smart( + proposal_module, + &QueryMsg::GetVote { + proposal_id, + voter: who.to_string(), + }, + ) + .unwrap() +} + +pub(crate) fn query_proposal_hooks(app: &App, proposal_single: &Addr) -> HooksResponse { + app.wrap() + .query_wasm_smart(proposal_single, &QueryMsg::ProposalHooks {}) + .unwrap() +} + +pub(crate) fn query_vote_hooks(app: &App, proposal_single: &Addr) -> HooksResponse { + app.wrap() + .query_wasm_smart(proposal_single, &QueryMsg::VoteHooks {}) + .unwrap() +} + +pub(crate) fn query_list_proposals_reverse( + app: &App, + proposal_single: &Addr, + start_before: Option, + limit: Option, +) -> ProposalListResponse { + app.wrap() + .query_wasm_smart( + proposal_single, + &QueryMsg::ReverseProposals { + start_before, + limit, + }, + ) + .unwrap() +} + +pub(crate) fn query_pre_proposal_single_config(app: &App, pre_propose: &Addr) -> cppbps::Config { + app.wrap() + .query_wasm_smart(pre_propose, &cppbps::QueryMsg::Config {}) + .unwrap() +} + +pub(crate) fn query_pre_proposal_single_deposit_info( + app: &App, + pre_propose: &Addr, + proposal_id: u64, +) -> cppbps::DepositInfoResponse { + app.wrap() + .query_wasm_smart(pre_propose, &cppbps::QueryMsg::DepositInfo { proposal_id }) + .unwrap() +} + +pub(crate) fn query_single_proposal_module(app: &App, core_addr: &Addr) -> Addr { + let modules: Vec = app + .wrap() + .query_wasm_smart( + core_addr, + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + // Filter out disabled modules. + let modules = modules + .into_iter() + .filter(|module| module.status == ProposalModuleStatus::Enabled) + .collect::>(); + + assert_eq!( + modules.len(), + 1, + "wrong proposal module count. expected 1, got {}", + modules.len() + ); + + modules.into_iter().next().unwrap().address +} + +pub(crate) fn query_dao_token(app: &App, core_addr: &Addr) -> Addr { + let voting_module = query_voting_module(app, core_addr); + app.wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap() +} + +pub(crate) fn query_voting_module(app: &App, core_addr: &Addr) -> Addr { + app.wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::VotingModule {}) + .unwrap() +} + +pub(crate) fn query_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +pub(crate) fn query_balance_native(app: &App, who: &str, denom: &str) -> Uint128 { + let res = app.wrap().query_balance(who, denom).unwrap(); + res.amount +} + +pub(crate) fn query_proposal(app: &App, proposal_single: &Addr, id: u64) -> ProposalResponse { + app.wrap() + .query_wasm_smart(proposal_single, &QueryMsg::Proposal { proposal_id: id }) + .unwrap() +} + +pub(crate) fn query_next_proposal_id(app: &App, proposal_single: &Addr) -> u64 { + app.wrap() + .query_wasm_smart(proposal_single, &QueryMsg::NextProposalId {}) + .unwrap() +} + +pub(crate) fn query_proposal_count(app: &App, proposal_single: &Addr) -> u64 { + app.wrap() + .query_wasm_smart(proposal_single, &QueryMsg::ProposalCount {}) + .unwrap() +} diff --git a/contracts/proposal/dao-proposal-single/src/testing/tests.rs b/contracts/proposal/dao-proposal-single/src/testing/tests.rs new file mode 100644 index 000000000..a4e6f4b46 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/testing/tests.rs @@ -0,0 +1,2685 @@ +use cosmwasm_std::{ + coins, + testing::{mock_dependencies, mock_env}, + to_binary, Addr, Attribute, BankMsg, Binary, ContractInfoResponse, CosmosMsg, Decimal, Empty, + Reply, StdError, SubMsgResult, Uint128, WasmMsg, WasmQuery, +}; +use cw2::ContractVersion; +use cw20::Cw20Coin; +use cw_denom::CheckedDenom; +use cw_hooks::{HookError, HooksResponse}; +use cw_multi_test::{next_block, App, Executor}; +use cw_utils::Duration; +use dao_interface::{ + state::{Admin, ModuleInstantiateInfo}, + voting::InfoResponse, +}; +use dao_testing::{ShouldExecute, TestSingleChoiceVote}; +use dao_voting::{ + deposit::{CheckedDepositInfo, UncheckedDepositInfo}, + pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + proposal::{SingleChoiceProposeMsg as ProposeMsg, MAX_PROPOSAL_SIZE}, + reply::{ + failed_pre_propose_module_hook_id, mask_proposal_execution_proposal_id, + mask_proposal_hook_index, mask_vote_hook_index, + }, + status::Status, + threshold::{ActiveThreshold, PercentageThreshold, Threshold}, + voting::{Vote, Votes}, +}; + +use crate::{ + contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}, + msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}, + proposal::SingleChoiceProposal, + query::{ProposalResponse, VoteInfo}, + state::Config, + testing::{ + contracts::{ + cw20_base_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, + cw_core_contract, pre_propose_single_contract, proposal_single_contract, + v1_proposal_single_contract, + }, + execute::{ + add_proposal_hook, add_proposal_hook_should_fail, add_vote_hook, + add_vote_hook_should_fail, close_proposal, close_proposal_should_fail, + execute_proposal, execute_proposal_should_fail, instantiate_cw20_base_default, + make_proposal, mint_cw20s, mint_natives, remove_proposal_hook, + remove_proposal_hook_should_fail, remove_vote_hook, remove_vote_hook_should_fail, + update_rationale, vote_on_proposal, vote_on_proposal_should_fail, + }, + instantiate::{ + get_default_non_token_dao_proposal_module_instantiate, + get_default_token_dao_proposal_module_instantiate, get_pre_propose_info, + instantiate_with_cw4_groups_governance, instantiate_with_staked_balances_governance, + instantiate_with_staking_active_threshold, + }, + queries::{ + query_balance_cw20, query_balance_native, query_creation_policy, query_dao_token, + query_deposit_config_and_pre_propose_module, query_list_proposals, + query_list_proposals_reverse, query_list_votes, query_pre_proposal_single_config, + query_pre_proposal_single_deposit_info, query_proposal, query_proposal_config, + query_proposal_hooks, query_single_proposal_module, query_vote_hooks, + query_voting_module, + }, + }, + ContractError, +}; + +use super::{ + do_votes::do_votes_staked_balances, + execute::vote_on_proposal_with_rationale, + queries::{query_next_proposal_id, query_vote}, + CREATOR_ADDR, +}; + +struct CommonTest { + app: App, + core_addr: Addr, + proposal_module: Addr, + gov_token: Addr, + proposal_id: u64, +} +fn setup_test(messages: Vec) -> CommonTest { + let mut app = App::default(); + let instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + // Mint some tokens to pay the proposal deposit. + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, messages); + + CommonTest { + app, + core_addr, + proposal_module, + gov_token, + proposal_id, + } +} + +#[test] +fn test_simple_propose_staked_balances() { + let CommonTest { + app, + core_addr: _, + proposal_module, + gov_token, + proposal_id, + } = setup_test(vec![]); + + let created = query_proposal(&app, &proposal_module, proposal_id); + let current_block = app.block_info(); + + // These values just come from the default instantiate message + // values. + let expected = SingleChoiceProposal { + title: "title".to_string(), + description: "description".to_string(), + proposer: Addr::unchecked(CREATOR_ADDR), + start_height: current_block.height, + expiration: Duration::Time(604800).after(¤t_block), + min_voting_period: None, + threshold: Threshold::ThresholdQuorum { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + threshold: PercentageThreshold::Majority {}, + }, + allow_revoting: false, + total_power: Uint128::new(100_000_000), + msgs: vec![], + status: Status::Open, + votes: Votes::zero(), + }; + + assert_eq!(created.proposal, expected); + assert_eq!(created.id, 1u64); + + // Check that the deposit info for this proposal looks right. + let (_, pre_propose) = query_deposit_config_and_pre_propose_module(&app, &proposal_module); + let deposit_response = query_pre_proposal_single_deposit_info(&app, &pre_propose, proposal_id); + + assert_eq!(deposit_response.proposer, Addr::unchecked(CREATOR_ADDR)); + assert_eq!( + deposit_response.deposit_info, + Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Cw20(gov_token), + amount: Uint128::new(10_000_000), + refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed + }) + ); +} + +#[test] +fn test_simple_proposal_cw4_voting() { + let mut app = App::default(); + let instantiate = get_default_non_token_dao_proposal_module_instantiate(&mut app); + let core_addr = instantiate_with_cw4_groups_governance(&mut app, instantiate, None); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + let created = query_proposal(&app, &proposal_module, id); + let current_block = app.block_info(); + + // These values just come from the default instantiate message + // values. + let expected = SingleChoiceProposal { + title: "title".to_string(), + description: "description".to_string(), + proposer: Addr::unchecked(CREATOR_ADDR), + start_height: current_block.height, + expiration: Duration::Time(604800).after(¤t_block), + min_voting_period: None, + threshold: Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(15)), + quorum: PercentageThreshold::Majority {}, + }, + allow_revoting: false, + total_power: Uint128::new(1), + msgs: vec![], + status: Status::Open, + votes: Votes::zero(), + }; + + assert_eq!(created.proposal, expected); + assert_eq!(created.id, 1u64); + + // Check that the deposit info for this proposal looks right. + let (_, pre_propose) = query_deposit_config_and_pre_propose_module(&app, &proposal_module); + let deposit_response = query_pre_proposal_single_deposit_info(&app, &pre_propose, id); + + assert_eq!(deposit_response.proposer, Addr::unchecked(CREATOR_ADDR)); + assert_eq!(deposit_response.deposit_info, None,); +} + +#[test] +fn test_propose_supports_stargate_messages() { + // If we can make a proposal with a stargate message, we support + // stargate messages in proposals. + setup_test(vec![CosmosMsg::Stargate { + type_url: "foo_type".to_string(), + value: Binary::default(), + }]); +} + +/// Test that the deposit token is properly set to the voting module +/// token during instantiation. +#[test] +fn test_voting_module_token_instantiate() { + let CommonTest { + app, + core_addr: _, + proposal_module, + gov_token, + proposal_id, + } = setup_test(vec![]); + + let (_, pre_propose) = query_deposit_config_and_pre_propose_module(&app, &proposal_module); + let deposit_response = query_pre_proposal_single_deposit_info(&app, &pre_propose, proposal_id); + + let deposit_token = if let Some(CheckedDepositInfo { + denom: CheckedDenom::Cw20(addr), + .. + }) = deposit_response.deposit_info + { + addr + } else { + panic!("voting module should have governance token") + }; + assert_eq!(deposit_token, gov_token) +} + +#[test] +#[should_panic( + expected = "Error parsing into type dao_voting_cw4::msg::QueryMsg: unknown variant `token_contract`" +)] +fn test_deposit_token_voting_module_token_fails_if_no_voting_module_token() { + let mut app = App::default(); + let instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate_with_cw4_groups_governance(&mut app, instantiate, None); +} + +#[test] +fn test_instantiate_with_non_voting_module_cw20_deposit() { + let mut app = App::default(); + let alt_cw20 = instantiate_cw20_base_default(&mut app); + + let mut instantiate = get_default_non_token_dao_proposal_module_instantiate(&mut app); + // hehehehehehehehe + instantiate.pre_propose_info = get_pre_propose_info( + &mut app, + Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::Token { + denom: cw_denom::UncheckedDenom::Cw20(alt_cw20.to_string()), + }, + amount: Uint128::new(10_000_000), + refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed, + }), + false, + ); + + let core_addr = instantiate_with_cw4_groups_governance(&mut app, instantiate, None); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + let created = query_proposal(&app, &proposal_module, proposal_id); + let current_block = app.block_info(); + + // These values just come from the default instantiate message + // values. + let expected = SingleChoiceProposal { + title: "title".to_string(), + description: "description".to_string(), + proposer: Addr::unchecked(CREATOR_ADDR), + start_height: current_block.height, + expiration: Duration::Time(604800).after(¤t_block), + min_voting_period: None, + threshold: Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(15)), + quorum: PercentageThreshold::Majority {}, + }, + allow_revoting: false, + total_power: Uint128::new(1), + msgs: vec![], + status: Status::Open, + votes: Votes::zero(), + }; + + assert_eq!(created.proposal, expected); + assert_eq!(created.id, 1u64); + + // Check that the deposit info for this proposal looks right. + let (_, pre_propose) = query_deposit_config_and_pre_propose_module(&app, &proposal_module); + let deposit_response = query_pre_proposal_single_deposit_info(&app, &pre_propose, proposal_id); + + assert_eq!(deposit_response.proposer, Addr::unchecked(CREATOR_ADDR)); + assert_eq!( + deposit_response.deposit_info, + Some(CheckedDepositInfo { + denom: cw_denom::CheckedDenom::Cw20(alt_cw20), + amount: Uint128::new(10_000_000), + refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed + }) + ); +} + +#[test] +fn test_proposal_message_execution() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.close_proposal_on_execution_failure = false; + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + vec![ + WasmMsg::Execute { + contract_addr: gov_token.to_string(), + msg: to_binary(&cw20::Cw20ExecuteMsg::Mint { + recipient: CREATOR_ADDR.to_string(), + amount: Uint128::new(10_000_000), + }) + .unwrap(), + funds: vec![], + } + .into(), + BankMsg::Send { + to_address: CREATOR_ADDR.to_string(), + amount: coins(10, "ujuno"), + } + .into(), + ], + ); + let cw20_balance = query_balance_cw20(&app, &gov_token, CREATOR_ADDR); + let native_balance = query_balance_native(&app, CREATOR_ADDR, "ujuno"); + assert_eq!(cw20_balance, Uint128::zero()); + assert_eq!(native_balance, Uint128::zero()); + + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + + // Can't use library function because we expect this to fail due + // to insufficent balance in the bank module. + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap_err(); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + + mint_natives(&mut app, core_addr.as_str(), coins(10, "ujuno")); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); + + let cw20_balance = query_balance_cw20(&app, &gov_token, CREATOR_ADDR); + let native_balance = query_balance_native(&app, CREATOR_ADDR, "ujuno"); + assert_eq!(cw20_balance, Uint128::new(20_000_000)); + assert_eq!(native_balance, Uint128::new(10)); + + // Sneak in a check here that proposals can't be executed more + // than once in the on close on execute config suituation. + let err = execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::NotPassed {})) +} + +#[test] +fn test_proposal_close_after_expiry() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token: _, + proposal_id, + } = setup_test(vec![BankMsg::Send { + to_address: CREATOR_ADDR.to_string(), + amount: coins(10, "ujuno"), + } + .into()]); + mint_natives(&mut app, core_addr.as_str(), coins(10, "ujuno")); + + // Try and close the proposal. This shoudl fail as the proposal is + // open. + let err = close_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::WrongCloseStatus {})); + + // Expire the proposal. Now it should be closable. + app.update_block(|b| b.time = b.time.plus_seconds(604800)); + close_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Closed); +} + +#[test] +fn test_proposal_cant_close_after_expiry_is_passed() { + let mut app = App::default(); + let instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "quorum".to_string(), + amount: Uint128::new(15), + }, + Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(85), + }, + ]), + ); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let gov_token = query_dao_token(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + mint_natives(&mut app, core_addr.as_str(), coins(10, "ujuno")); + let proposal_id = make_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + vec![BankMsg::Send { + to_address: CREATOR_ADDR.to_string(), + amount: coins(10, "ujuno"), + } + .into()], + ); + vote_on_proposal(&mut app, &proposal_module, "quorum", proposal_id, Vote::Yes); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Open); + + // Expire the proposal. This should pass it. + app.update_block(|b| b.time = b.time.plus_seconds(604800)); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + + // Make sure it can't be closed. + let err = close_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::WrongCloseStatus {})); + + // Executed proposals may not be closed. + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + let err = close_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::WrongCloseStatus {})); + let balance = query_balance_native(&app, CREATOR_ADDR, "ujuno"); + assert_eq!(balance, Uint128::new(10)); + let err = close_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::WrongCloseStatus {})); +} + +#[test] +fn test_execute_no_non_passed_execution() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token, + proposal_id, + } = setup_test(vec![BankMsg::Send { + to_address: CREATOR_ADDR.to_string(), + amount: coins(10, "ujuno"), + } + .into()]); + mint_natives(&mut app, core_addr.as_str(), coins(100, "ujuno")); + + let err = execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::NotPassed {})); + + // Expire the proposal. + app.update_block(|b| b.time = b.time.plus_seconds(604800)); + let err = execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::NotPassed {})); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + // Can't execute more than once. + let err = execute_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::NotPassed {})); +} + +#[test] +fn test_cant_execute_not_member_when_proposal_created() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token, + proposal_id, + } = setup_test(vec![BankMsg::Send { + to_address: CREATOR_ADDR.to_string(), + amount: coins(10, "ujuno"), + } + .into()]); + mint_natives(&mut app, core_addr.as_str(), coins(100, "ujuno")); + + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + + // Give noah some tokens. + mint_cw20s(&mut app, &gov_token, &core_addr, "noah", 20_000_000); + // Have noah stake some. + let voting_module = query_voting_module(&app, &core_addr); + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + app.execute_contract( + Addr::unchecked("noah"), + gov_token, + &cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(10_000_000), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + // Update the block so that the staked balance appears. + app.update_block(|block| block.height += 1); + + // Can't execute from member who wasn't a member when the proposal was + // created. + let err = execute_proposal_should_fail(&mut app, &proposal_module, "noah", proposal_id); + assert!(matches!(err, ContractError::Unauthorized {})); +} + +#[test] +fn test_update_config() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token: _, + proposal_id, + } = setup_test(vec![]); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + // Make a proposal to update the config. + let proposal_id = make_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + vec![WasmMsg::Execute { + contract_addr: proposal_module.to_string(), + msg: to_binary(&ExecuteMsg::UpdateConfig { + threshold: Threshold::AbsoluteCount { + threshold: Uint128::new(10_000), + }, + max_voting_period: Duration::Height(6), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + dao: core_addr.to_string(), + close_proposal_on_execution_failure: false, + }) + .unwrap(), + funds: vec![], + } + .into()], + ); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + let config = query_proposal_config(&app, &proposal_module); + assert_eq!( + config, + Config { + threshold: Threshold::AbsoluteCount { + threshold: Uint128::new(10_000) + }, + max_voting_period: Duration::Height(6), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + dao: core_addr.clone(), + close_proposal_on_execution_failure: false, + } + ); + + // Check that non-dao address may not update config. + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module, + &&ExecuteMsg::UpdateConfig { + threshold: Threshold::AbsoluteCount { + threshold: Uint128::new(10_000), + }, + max_voting_period: Duration::Height(6), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + dao: core_addr.to_string(), + close_proposal_on_execution_failure: false, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::Unauthorized {})) +} + +#[test] +fn test_anyone_may_propose_and_proposal_listing() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.pre_propose_info = PreProposeInfo::AnyoneMayPropose {}; + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let proposal_module = query_single_proposal_module(&app, &core_addr); + + for addr in 'm'..'z' { + let addr = addr.to_string().repeat(6); + let proposal_id = make_proposal(&mut app, &proposal_module, &addr, vec![]); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + // Only members can execute still. + let err = execute_proposal_should_fail(&mut app, &proposal_module, &addr, proposal_id); + assert!(matches!(err, ContractError::Unauthorized {})); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + } + + // Now that we've got all these proposals sitting around, lets + // test that we can query them. + + let proposals_forward = query_list_proposals(&app, &proposal_module, None, None); + let mut proposals_reverse = query_list_proposals_reverse(&app, &proposal_module, None, None); + proposals_reverse.proposals.reverse(); + assert_eq!(proposals_reverse, proposals_forward); + + // Check the proposers and (implicitly) the ordering. + for (index, addr) in ('m'..'z').enumerate() { + let addr = addr.to_string().repeat(6); + assert_eq!( + proposals_forward.proposals[index].proposal.proposer, + Addr::unchecked(addr) + ) + } + + let four_and_five = query_list_proposals(&app, &proposal_module, Some(3), Some(2)); + let mut five_and_four = query_list_proposals_reverse(&app, &proposal_module, Some(6), Some(2)); + five_and_four.proposals.reverse(); + + assert_eq!(five_and_four, four_and_five); + assert_eq!( + four_and_five.proposals[0].proposal.proposer, + Addr::unchecked("pppppp") + ); + + let current_block = app.block_info(); + assert_eq!( + four_and_five.proposals[0], + ProposalResponse { + id: 4, + proposal: SingleChoiceProposal { + title: "title".to_string(), + description: "description".to_string(), + proposer: Addr::unchecked("pppppp"), + start_height: current_block.height, + min_voting_period: None, + expiration: Duration::Time(604800).after(¤t_block), + threshold: Threshold::ThresholdQuorum { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + threshold: PercentageThreshold::Majority {}, + }, + allow_revoting: false, + total_power: Uint128::new(100_000_000), + msgs: vec![], + status: Status::Executed, + votes: Votes { + yes: Uint128::new(100_000_000), + no: Uint128::zero(), + abstain: Uint128::zero() + }, + } + } + ) +} + +#[test] +fn test_proposal_hook_registration() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token: _, + proposal_id: _, + } = setup_test(vec![]); + + let proposal_hooks = query_proposal_hooks(&app, &proposal_module); + assert_eq!( + proposal_hooks.hooks.len(), + 0, + "pre-propose deposit module should not show on this listing" + ); + + // non-dao may not add a hook. + let err = + add_proposal_hook_should_fail(&mut app, &proposal_module, CREATOR_ADDR, "proposalhook"); + assert!(matches!(err, ContractError::Unauthorized {})); + + add_proposal_hook( + &mut app, + &proposal_module, + core_addr.as_str(), + "proposalhook", + ); + let err = add_proposal_hook_should_fail( + &mut app, + &proposal_module, + core_addr.as_str(), + "proposalhook", + ); + assert!(matches!( + err, + ContractError::HookError(HookError::HookAlreadyRegistered {}) + )); + + let proposal_hooks = query_proposal_hooks(&app, &proposal_module); + assert_eq!(proposal_hooks.hooks[0], "proposalhook".to_string()); + + // Only DAO can remove proposal hooks. + let err = + remove_proposal_hook_should_fail(&mut app, &proposal_module, CREATOR_ADDR, "proposalhook"); + assert!(matches!(err, ContractError::Unauthorized {})); + remove_proposal_hook( + &mut app, + &proposal_module, + core_addr.as_str(), + "proposalhook", + ); + let proposal_hooks = query_proposal_hooks(&app, &proposal_module); + assert_eq!(proposal_hooks.hooks.len(), 0); + + // Can not remove that which does not exist. + let err = remove_proposal_hook_should_fail( + &mut app, + &proposal_module, + core_addr.as_str(), + "proposalhook", + ); + assert!(matches!( + err, + ContractError::HookError(HookError::HookNotRegistered {}) + )); +} + +#[test] +fn test_vote_hook_registration() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token: _, + proposal_id: _, + } = setup_test(vec![]); + + let vote_hooks = query_vote_hooks(&app, &proposal_module); + assert!(vote_hooks.hooks.is_empty(),); + + // non-dao may not add a hook. + let err = add_vote_hook_should_fail(&mut app, &proposal_module, CREATOR_ADDR, "votehook"); + assert!(matches!(err, ContractError::Unauthorized {})); + + add_vote_hook(&mut app, &proposal_module, core_addr.as_str(), "votehook"); + + let vote_hooks = query_vote_hooks(&app, &proposal_module); + assert_eq!( + vote_hooks, + HooksResponse { + hooks: vec!["votehook".to_string()] + } + ); + + let err = add_vote_hook_should_fail(&mut app, &proposal_module, core_addr.as_str(), "votehook"); + assert!(matches!( + err, + ContractError::HookError(HookError::HookAlreadyRegistered {}) + )); + + let vote_hooks = query_vote_hooks(&app, &proposal_module); + assert_eq!(vote_hooks.hooks[0], "votehook".to_string()); + + // Only DAO can remove vote hooks. + let err = remove_vote_hook_should_fail(&mut app, &proposal_module, CREATOR_ADDR, "votehook"); + assert!(matches!(err, ContractError::Unauthorized {})); + remove_vote_hook(&mut app, &proposal_module, core_addr.as_str(), "votehook"); + + let vote_hooks = query_vote_hooks(&app, &proposal_module); + assert!(vote_hooks.hooks.is_empty(),); + + // Can not remove that which does not exist. + let err = + remove_vote_hook_should_fail(&mut app, &proposal_module, core_addr.as_str(), "votehook"); + assert!(matches!( + err, + ContractError::HookError(HookError::HookNotRegistered {}) + )); +} + +#[test] +fn test_active_threshold_absolute() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.pre_propose_info = PreProposeInfo::AnyoneMayPropose {}; + let core_addr = instantiate_with_staking_active_threshold( + &mut app, + instantiate, + None, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + ); + let gov_token = query_dao_token(&app, &core_addr); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let voting_module = query_voting_module(&app, &core_addr); + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Propose(ProposeMsg { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::InactiveDao {})); + + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(100), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), gov_token, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // Proposal creation now works as tokens have been staked to reach + // active threshold. + make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + // Unstake some tokens to make it inactive again. + let msg = cw20_stake::msg::ExecuteMsg::Unstake { + amount: Uint128::new(50), + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), staking_contract, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Propose(ProposeMsg { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::InactiveDao {})); +} + +#[test] +fn test_active_threshold_percent() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.pre_propose_info = PreProposeInfo::AnyoneMayPropose {}; + let core_addr = instantiate_with_staking_active_threshold( + &mut app, + instantiate, + None, + Some(ActiveThreshold::Percentage { + percent: Decimal::percent(20), + }), + ); + let gov_token = query_dao_token(&app, &core_addr); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let voting_module = query_voting_module(&app, &core_addr); + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Propose(ProposeMsg { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::InactiveDao {})); + + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount: Uint128::new(20_000_000), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), gov_token, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // Proposal creation now works as tokens have been staked to reach + // active threshold. + make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + // Unstake some tokens to make it inactive again. + let msg = cw20_stake::msg::ExecuteMsg::Unstake { + amount: Uint128::new(1), // Only one is needed as we're right + // on the edge. :) + }; + app.execute_contract(Addr::unchecked(CREATOR_ADDR), staking_contract, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Propose(ProposeMsg { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::InactiveDao {})); +} + +#[test] +#[should_panic( + expected = "min_voting_period and max_voting_period must have the same units (height or time)" +)] +fn test_min_duration_unit_missmatch() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.min_voting_period = Some(Duration::Height(10)); + instantiate_with_staked_balances_governance(&mut app, instantiate, None); +} + +#[test] +#[should_panic(expected = "Min voting period must be less than or equal to max voting period")] +fn test_min_duration_larger_than_proposal_duration() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.min_voting_period = Some(Duration::Time(604801)); + instantiate_with_staked_balances_governance(&mut app, instantiate, None); +} + +#[test] +fn test_min_voting_period_no_early_pass() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.min_voting_period = Some(Duration::Height(10)); + instantiate.max_voting_period = Duration::Height(100); + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let gov_token = query_dao_token(&app, &core_addr); + let proposal_module = query_single_proposal_module(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + let proposal_response = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal_response.proposal.status, Status::Open); + + app.update_block(|block| block.height += 10); + let proposal_response = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal_response.proposal.status, Status::Passed); +} + +// Setting the min duration the same as the proposal duration just +// means that proposals cant close early. +#[test] +fn test_min_duration_same_as_proposal_duration() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.min_voting_period = Some(Duration::Height(100)); + instantiate.max_voting_period = Duration::Height(100); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "whale".to_string(), + amount: Uint128::new(90), + }, + ]), + ); + let gov_token = query_dao_token(&app, &core_addr); + let proposal_module = query_single_proposal_module(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, "ekez", 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, "ekez", vec![]); + + // Whale votes yes. Normally the proposal would just pass and ekez + // would be out of luck. + vote_on_proposal(&mut app, &proposal_module, "whale", proposal_id, Vote::Yes); + vote_on_proposal(&mut app, &proposal_module, "ekez", proposal_id, Vote::No); + + app.update_block(|b| b.height += 100); + let proposal_response = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal_response.proposal.status, Status::Passed); +} + +#[test] +fn test_revoting_playthrough() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.allow_revoting = true; + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let gov_token = query_dao_token(&app, &core_addr); + let proposal_module = query_single_proposal_module(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + // Vote and change our minds a couple times. + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + let proposal_response = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal_response.proposal.status, Status::Open); + + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::No, + ); + let proposal_response = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal_response.proposal.status, Status::Open); + + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + let proposal_response = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal_response.proposal.status, Status::Open); + + // Can't cast the same vote more than once. + let err = vote_on_proposal_should_fail( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + assert!(matches!(err, ContractError::AlreadyCast {})); + + // Expire the proposal allowing the votes to be tallied. + app.update_block(|b| b.time = b.time.plus_seconds(604800)); + let proposal_response = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal_response.proposal.status, Status::Passed); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + // Can't vote once the proposal is passed. + let err = vote_on_proposal_should_fail( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + assert!(matches!(err, ContractError::Expired { .. })); +} + +/// Tests that revoting is stored at a per-proposal level. Proposals +/// created while revoting is enabled should not have it disabled if a +/// config change turns if off. +#[test] +fn test_allow_revoting_config_changes() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.allow_revoting = true; + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let gov_token = query_dao_token(&app, &core_addr); + let proposal_module = query_single_proposal_module(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + // This proposal should have revoting enable for its entire + // lifetime. + let revoting_proposal = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + // Update the config of the proposal module to disable revoting. + app.execute_contract( + core_addr.clone(), + proposal_module.clone(), + &ExecuteMsg::UpdateConfig { + threshold: Threshold::ThresholdQuorum { + quorum: PercentageThreshold::Percent(Decimal::percent(15)), + threshold: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Height(10), + min_voting_period: None, + only_members_execute: true, + // Turn off revoting. + allow_revoting: false, + dao: core_addr.to_string(), + close_proposal_on_execution_failure: false, + }, + &[], + ) + .unwrap(); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let no_revoting_proposal = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + revoting_proposal, + Vote::Yes, + ); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + no_revoting_proposal, + Vote::Yes, + ); + + // Proposal without revoting should have passed. + let proposal_resp = query_proposal(&app, &proposal_module, no_revoting_proposal); + assert_eq!(proposal_resp.proposal.status, Status::Passed); + + // Proposal with revoting should not have passed. + let proposal_resp = query_proposal(&app, &proposal_module, revoting_proposal); + assert_eq!(proposal_resp.proposal.status, Status::Open); + + // Can change vote on the revoting proposal. + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + revoting_proposal, + Vote::No, + ); + // Expire the revoting proposal and close it. + app.update_block(|b| b.time = b.time.plus_seconds(604800)); + close_proposal(&mut app, &proposal_module, CREATOR_ADDR, revoting_proposal); +} + +/// Tests a simple three of five multisig configuration. +#[test] +fn test_three_of_five_multisig() { + let mut app = App::default(); + let mut instantiate = get_default_non_token_dao_proposal_module_instantiate(&mut app); + instantiate.threshold = Threshold::AbsoluteCount { + threshold: Uint128::new(3), + }; + instantiate.pre_propose_info = PreProposeInfo::AnyoneMayPropose {}; + let core_addr = instantiate_with_cw4_groups_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "one".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "two".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "three".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "four".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "five".to_string(), + amount: Uint128::new(1), + }, + ]), + ); + + let core_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_module = core_state + .proposal_modules + .into_iter() + .next() + .unwrap() + .address; + + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + vote_on_proposal(&mut app, &proposal_module, "one", proposal_id, Vote::Yes); + vote_on_proposal(&mut app, &proposal_module, "two", proposal_id, Vote::Yes); + + // Make sure it doesn't pass early. + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Open); + + vote_on_proposal(&mut app, &proposal_module, "three", proposal_id, Vote::Yes); + + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Passed); + + execute_proposal(&mut app, &proposal_module, "four", proposal_id); + + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, 1); + assert_eq!(proposal.proposal.status, Status::Executed); + + // Make another proposal which we'll reject. + let proposal_id = make_proposal(&mut app, &proposal_module, "one", vec![]); + + vote_on_proposal(&mut app, &proposal_module, "one", proposal_id, Vote::Yes); + vote_on_proposal(&mut app, &proposal_module, "two", proposal_id, Vote::No); + vote_on_proposal(&mut app, &proposal_module, "three", proposal_id, Vote::No); + vote_on_proposal(&mut app, &proposal_module, "four", proposal_id, Vote::No); + + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Rejected); + + close_proposal(&mut app, &proposal_module, "four", proposal_id); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Closed); +} + +#[test] +fn test_three_of_five_multisig_revoting() { + let mut app = App::default(); + let mut instantiate = get_default_non_token_dao_proposal_module_instantiate(&mut app); + instantiate.threshold = Threshold::AbsoluteCount { + threshold: Uint128::new(3), + }; + instantiate.allow_revoting = true; + instantiate.pre_propose_info = PreProposeInfo::AnyoneMayPropose {}; + let core_addr = instantiate_with_cw4_groups_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "one".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "two".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "three".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "four".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "five".to_string(), + amount: Uint128::new(1), + }, + ]), + ); + + let core_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_module = core_state + .proposal_modules + .into_iter() + .next() + .unwrap() + .address; + + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + vote_on_proposal(&mut app, &proposal_module, "one", proposal_id, Vote::Yes); + vote_on_proposal(&mut app, &proposal_module, "two", proposal_id, Vote::Yes); + + // Make sure it doesn't pass early. + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Open); + + vote_on_proposal(&mut app, &proposal_module, "three", proposal_id, Vote::Yes); + + // Revoting is enabled so the proposal is still open. + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Open); + + // Change our minds. + vote_on_proposal(&mut app, &proposal_module, "one", proposal_id, Vote::No); + vote_on_proposal(&mut app, &proposal_module, "two", proposal_id, Vote::No); + + let err = + vote_on_proposal_should_fail(&mut app, &proposal_module, "two", proposal_id, Vote::No); + assert!(matches!(err, ContractError::AlreadyCast {})); + + // Expire the revoting proposal and close it. + app.update_block(|b| b.time = b.time.plus_seconds(604800)); + let proposal: ProposalResponse = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Rejected); +} + +/// Tests that absolute count style thresholds work with token style +/// voting. +#[test] +fn test_absolute_count_threshold_non_multisig() { + do_votes_staked_balances( + vec![ + TestSingleChoiceVote { + voter: "one".to_string(), + position: Vote::Yes, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "two".to_string(), + position: Vote::No, + weight: Uint128::new(200), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "three".to_string(), + position: Vote::Yes, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::AbsoluteCount { + threshold: Uint128::new(11), + }, + Status::Passed, + None, + ); +} + +/// Tests that we do not overflow when faced with really high token / +/// vote supply. +#[test] +fn test_large_absolute_count_threshold() { + do_votes_staked_balances( + vec![ + TestSingleChoiceVote { + voter: "two".to_string(), + position: Vote::No, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }, + // Can vote up to expiration time. + TestSingleChoiceVote { + voter: "one".to_string(), + position: Vote::Yes, + weight: Uint128::new(u128::MAX - 1), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::AbsoluteCount { + threshold: Uint128::new(u128::MAX), + }, + Status::Rejected, + None, + ); + + do_votes_staked_balances( + vec![ + TestSingleChoiceVote { + voter: "one".to_string(), + position: Vote::Yes, + weight: Uint128::new(u128::MAX - 1), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "two".to_string(), + position: Vote::No, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::AbsoluteCount { + threshold: Uint128::new(u128::MAX), + }, + Status::Rejected, + None, + ); +} + +#[test] +fn test_proposal_count_initialized_to_zero() { + let mut app = App::default(); + let pre_propose_info = get_pre_propose_info(&mut app, None, false); + let core_addr = instantiate_with_staked_balances_governance( + &mut app, + InstantiateMsg { + threshold: Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: Duration::Height(10), + min_voting_period: None, + only_members_execute: true, + allow_revoting: false, + pre_propose_info, + close_proposal_on_execution_failure: true, + }, + Some(vec![ + Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "innactive".to_string(), + amount: Uint128::new(90), + }, + ]), + ); + + let core_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart(core_addr, &dao_interface::msg::QueryMsg::DumpState {}) + .unwrap(); + let proposal_modules = core_state.proposal_modules; + + assert_eq!(proposal_modules.len(), 1); + let proposal_single = proposal_modules.into_iter().next().unwrap().address; + + let proposal_count: u64 = app + .wrap() + .query_wasm_smart(proposal_single, &QueryMsg::ProposalCount {}) + .unwrap(); + assert_eq!(proposal_count, 0); +} + +#[test] +fn test_migrate_from_compatible() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token: _, + proposal_id: _, + } = setup_test(vec![]); + + let new_code_id = app.store_code(proposal_single_contract()); + let start_config = query_proposal_config(&app, &proposal_module); + + app.execute( + core_addr, + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: proposal_module.to_string(), + new_code_id, + msg: to_binary(&MigrateMsg::FromCompatible {}).unwrap(), + }), + ) + .unwrap(); + + let end_config = query_proposal_config(&app, &proposal_module); + assert_eq!(start_config, end_config); +} + +#[test] +pub fn test_migrate_updates_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").unwrap(); + migrate(deps.as_mut(), mock_env(), MigrateMsg::FromCompatible {}).unwrap(); + let version = cw2::get_contract_version(&deps.storage).unwrap(); + assert_eq!(version.version, CONTRACT_VERSION); + assert_eq!(version.contract, CONTRACT_NAME); +} + +/// Instantiates a DAO with a v1 proposal module and then migrates it +/// to v2. +#[test] +fn test_migrate_from_v1() { + use cw_proposal_single_v1 as v1; + use dao_pre_propose_single as cppbps; + + let mut app = App::default(); + let v1_proposal_single_code = app.store_code(v1_proposal_single_contract()); + + let instantiate = v1::msg::InstantiateMsg { + threshold: voting_v1::Threshold::AbsolutePercentage { + percentage: voting_v1::PercentageThreshold::Majority {}, + }, + max_voting_period: cw_utils_v1::Duration::Height(6), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + deposit_info: Some(v1::msg::DepositInfo { + token: v1::msg::DepositToken::VotingModuleToken {}, + deposit: Uint128::new(1), + refund_failed_proposals: true, + }), + }; + + let initial_balances = vec![Cw20Coin { + amount: Uint128::new(100), + address: CREATOR_ADDR.to_string(), + }]; + + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_stake_id = app.store_code(cw20_stake_contract()); + let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); + let core_contract_id = app.store_code(cw_core_contract()); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + dao_uri: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: staked_balances_voting_id, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: None, + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token.".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: initial_balances.clone(), + marketing: None, + staking_code_id: cw20_stake_id, + unstaking_duration: Some(Duration::Height(6)), + initial_dao_balance: None, + }, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: v1_proposal_single_code, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: to_binary(&instantiate).unwrap(), + }], + initial_items: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let core_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let voting_module = core_state.voting_module; + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Stake all the initial balances. + for Cw20Coin { address, amount } in initial_balances { + app.execute_contract( + Addr::unchecked(address), + token_contract.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount, + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + } + + // Update the block so that those staked balances appear. + app.update_block(|block| block.height += 1); + + let proposal_module = query_single_proposal_module(&app, &core_addr); + + // Make a proposal so we can test that migration doesn't work with + // open proposals that have deposits. + mint_cw20s(&mut app, &token_contract, &core_addr, CREATOR_ADDR, 1); + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_contract.clone(), + &cw20::Cw20ExecuteMsg::IncreaseAllowance { + spender: proposal_module.to_string(), + amount: Uint128::new(1), + expires: None, + }, + &[], + ) + .unwrap(); + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &v1::msg::ExecuteMsg::Propose { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + }, + &[], + ) + .unwrap(); + + let v2_proposal_single = app.store_code(proposal_single_contract()); + let pre_propose_single = app.store_code(pre_propose_single_contract()); + + // Attempt to migrate. This will fail as there is a pending + // proposal. + let migrate_msg = MigrateMsg::FromV1 { + close_proposal_on_execution_failure: true, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_single, + msg: to_binary(&dao_pre_propose_single::InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO pre-propose".to_string(), + }, + }, + }; + let err: ContractError = app + .execute( + core_addr.clone(), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: proposal_module.to_string(), + new_code_id: v2_proposal_single, + msg: to_binary(&migrate_msg).unwrap(), + }), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::PendingProposals {})); + + // Vote on and close the pending proposal. + vote_on_proposal(&mut app, &proposal_module, CREATOR_ADDR, 1, Vote::No); + close_proposal(&mut app, &proposal_module, CREATOR_ADDR, 1); + + // Now we can migrate! + app.execute( + core_addr.clone(), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: proposal_module.to_string(), + new_code_id: v2_proposal_single, + msg: to_binary(&migrate_msg).unwrap(), + }), + ) + .unwrap(); + + let new_config = query_proposal_config(&app, &proposal_module); + assert_eq!( + new_config, + Config { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {} + }, + max_voting_period: Duration::Height(6), + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + dao: core_addr.clone(), + close_proposal_on_execution_failure: true, + } + ); + + // We can not migrate more than once. + let err: ContractError = app + .execute( + core_addr.clone(), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: proposal_module.to_string(), + new_code_id: v2_proposal_single, + msg: to_binary(&migrate_msg).unwrap(), + }), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::AlreadyMigrated {})); + + // Make sure we can still query for ballots (rationale works post + // migration). + let vote = query_vote(&app, &proposal_module, CREATOR_ADDR, 1); + assert_eq!( + vote.vote.unwrap(), + VoteInfo { + voter: Addr::unchecked(CREATOR_ADDR), + vote: Vote::No, + power: Uint128::new(100), + rationale: None + } + ); + + let proposal_creation_policy = query_creation_policy(&app, &proposal_module); + + // Check that a new creation policy has been birthed. + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => panic!("expected a pre-propose module"), + ProposalCreationPolicy::Module { addr } => addr, + }; + let pre_propose_config = query_pre_proposal_single_config(&app, &pre_propose); + assert_eq!( + pre_propose_config, + cppbps::Config { + open_proposal_submission: false, + deposit_info: Some(CheckedDepositInfo { + denom: CheckedDenom::Cw20(token_contract.clone()), + amount: Uint128::new(1), + refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed, + }) + } + ); + + // Make sure we can still make a proposal and vote on it. + mint_cw20s(&mut app, &token_contract, &core_addr, CREATOR_ADDR, 1); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Executed); +} + +// - Make a proposal that will fail to execute. +// - Verify that it goes to execution failed and that proposal +// deposits are returned once and not on closing. +// - Make the same proposal again. +// - Update the config to disable close on execution failure. +// - Make sure that proposal doesn't close on execution (this config +// feature gets applied retroactively). +#[test] +fn test_execution_failed() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token, + proposal_id, + } = setup_test(vec![BankMsg::Send { + to_address: "ekez".to_string(), + amount: coins(10, "ujuno"), + } + .into()]); + + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::ExecutionFailed); + + // Make sure the deposit was returned. + let balance = query_balance_cw20(&app, &gov_token, CREATOR_ADDR); + assert_eq!(balance, Uint128::new(10_000_000)); + + // ExecutionFailed is an end state. + let err = close_proposal_should_fail(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + assert!(matches!(err, ContractError::WrongCloseStatus {})); + + let proposal_id = make_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + vec![BankMsg::Send { + to_address: "ekez".to_string(), + amount: coins(10, "ujuno"), + } + .into()], + ); + + let config = query_proposal_config(&app, &proposal_module); + + // Disable execution failing proposals. + app.execute_contract( + core_addr, + proposal_module.clone(), + &ExecuteMsg::UpdateConfig { + threshold: config.threshold, + max_voting_period: config.max_voting_period, + min_voting_period: config.min_voting_period, + only_members_execute: config.only_members_execute, + allow_revoting: config.allow_revoting, + dao: config.dao.into_string(), + // Disable. + close_proposal_on_execution_failure: false, + }, + &[], + ) + .unwrap(); + + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + let err: StdError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, StdError::Overflow { .. })); + + // Even though this proposal was created before the config change + // was made it still gets retroactively applied. + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Passed); + + // This proposal's deposit should not have been returned. It will + // not be returnable until this is executed, or close on execution + // is re-enabled. + let balance = query_balance_cw20(&app, &gov_token, CREATOR_ADDR); + assert_eq!(balance, Uint128::zero()); +} + +#[test] +fn test_reply_proposal_mock() { + use crate::contract::reply; + use crate::state::PROPOSALS; + + let mut deps = mock_dependencies(); + let env = mock_env(); + + let m_proposal_id = mask_proposal_execution_proposal_id(1); + PROPOSALS + .save( + deps.as_mut().storage, + 1, + &SingleChoiceProposal { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + proposer: Addr::unchecked(CREATOR_ADDR), + start_height: env.block.height, + expiration: cw_utils::Duration::Height(6).after(&env.block), + min_voting_period: None, + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + allow_revoting: false, + total_power: Uint128::new(100_000_000), + msgs: vec![], + status: Status::Open, + votes: Votes::zero(), + }, + ) + .unwrap(); + + // PROPOSALS + let reply_msg = Reply { + id: m_proposal_id, + result: SubMsgResult::Err("error_msg".to_string()), + }; + let res = reply(deps.as_mut(), env, reply_msg).unwrap(); + assert_eq!( + res.attributes[0], + Attribute { + key: "proposal_execution_failed".to_string(), + value: 1.to_string() + } + ); + + let prop = PROPOSALS.load(deps.as_mut().storage, 1).unwrap(); + assert_eq!(prop.status, Status::ExecutionFailed); +} + +#[test] +fn test_proposal_too_large() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.pre_propose_info = PreProposeInfo::AnyoneMayPropose {}; + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let proposal_module = query_single_proposal_module(&app, &core_addr); + + let err = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module, + &ExecuteMsg::Propose(ProposeMsg { + title: "".to_string(), + description: "a".repeat(MAX_PROPOSAL_SIZE as usize), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!( + err, + ContractError::ProposalTooLarge { + size: _, + max: MAX_PROPOSAL_SIZE + } + )) +} + +#[test] +fn test_vote_not_registered() { + let CommonTest { + mut app, + core_addr: _, + proposal_module, + gov_token: _, + proposal_id, + } = setup_test(vec![]); + + let err = + vote_on_proposal_should_fail(&mut app, &proposal_module, "ekez", proposal_id, Vote::Yes); + assert!(matches!(err, ContractError::NotRegistered {})) +} + +#[test] +fn test_proposal_creation_permissions() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token: _, + proposal_id: _, + } = setup_test(vec![]); + + // Non pre-propose may not propose. + let err = app + .execute_contract( + Addr::unchecked("notprepropose"), + proposal_module.clone(), + &ExecuteMsg::Propose(ProposeMsg { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::Unauthorized {})); + + let proposal_creation_policy = query_creation_policy(&app, &proposal_module); + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => panic!("expected a pre-propose module"), + ProposalCreationPolicy::Module { addr } => addr, + }; + + // Proposer may not be none when a pre-propose module is making + // the proposal. + let err = app + .execute_contract( + pre_propose, + proposal_module.clone(), + &ExecuteMsg::Propose(ProposeMsg { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::InvalidProposer {})); + + // Allow anyone to propose. + app.execute_contract( + core_addr, + proposal_module.clone(), + &ExecuteMsg::UpdatePreProposeInfo { + info: PreProposeInfo::AnyoneMayPropose {}, + }, + &[], + ) + .unwrap(); + + // Proposer must be None when non pre-propose module is making the + // proposal. + let err = app + .execute_contract( + Addr::unchecked("ekez"), + proposal_module.clone(), + &ExecuteMsg::Propose(ProposeMsg { + title: "title".to_string(), + description: "description".to_string(), + msgs: vec![], + proposer: Some("ekez".to_string()), + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::InvalidProposer {})); + + // Works normally. + let proposal_id = make_proposal(&mut app, &proposal_module, "ekez", vec![]); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.proposer, Addr::unchecked("ekez")); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::No, + ); + close_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); +} + +#[test] +fn test_reply_hooks_mock() { + use crate::contract::reply; + use crate::state::{CREATION_POLICY, PROPOSAL_HOOKS, VOTE_HOOKS}; + + let mut deps = mock_dependencies(); + let env = mock_env(); + + // Add a proposal hook and remove it + let m_proposal_hook_idx = mask_proposal_hook_index(0); + PROPOSAL_HOOKS + .add_hook(deps.as_mut().storage, Addr::unchecked(CREATOR_ADDR)) + .unwrap(); + + let reply_msg = Reply { + id: m_proposal_hook_idx, + result: SubMsgResult::Err("error_msg".to_string()), + }; + + let res = reply(deps.as_mut(), env.clone(), reply_msg).unwrap(); + assert_eq!( + res.attributes[0], + Attribute { + key: "removed_proposal_hook".to_string(), + value: format! {"{CREATOR_ADDR}:{}", 0} + } + ); + + // Reply needs a creation policy in state. + CREATION_POLICY + .save( + deps.as_mut().storage, + &ProposalCreationPolicy::Module { + addr: Addr::unchecked("ekez"), + }, + ) + .unwrap(); + + let prepropose_reply_msg = Reply { + id: failed_pre_propose_module_hook_id(), + result: SubMsgResult::Err("error_msg".to_string()), + }; + + // Remove the pre-propose module as part of a reply. + let res = reply(deps.as_mut(), env.clone(), prepropose_reply_msg.clone()).unwrap(); + assert_eq!( + res.attributes[0], + Attribute { + key: "failed_prepropose_hook".to_string(), + value: "ekez".into() + } + ); + + // Do it again. This time, there is no module so this should error. + let _id = failed_pre_propose_module_hook_id(); + let err = reply(deps.as_mut(), env.clone(), prepropose_reply_msg).unwrap_err(); + assert!(matches!(err, ContractError::InvalidReplyID { id: _ })); + + // Check that we fail open. + let status = CREATION_POLICY.load(deps.as_ref().storage).unwrap(); + assert!(matches!(status, ProposalCreationPolicy::Anyone {})); + + // Vote hook + let m_vote_hook_idx = mask_vote_hook_index(0); + VOTE_HOOKS + .add_hook(deps.as_mut().storage, Addr::unchecked(CREATOR_ADDR)) + .unwrap(); + + let reply_msg = Reply { + id: m_vote_hook_idx, + result: SubMsgResult::Err("error_msg".to_string()), + }; + let res = reply(deps.as_mut(), env, reply_msg).unwrap(); + assert_eq!( + res.attributes[0], + Attribute { + key: "removed_vote_hook".to_string(), + value: format! {"{CREATOR_ADDR}:{}", 0} + } + ); +} + +#[test] +fn test_query_info() { + let CommonTest { + app, + core_addr: _, + proposal_module, + gov_token: _, + proposal_id: _, + } = setup_test(vec![]); + let info: InfoResponse = app + .wrap() + .query_wasm_smart(proposal_module, &QueryMsg::Info {}) + .unwrap(); + assert_eq!( + info, + InfoResponse { + info: ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string() + } + } + ) +} + +// Make a little multisig and test that queries to list votes work as +// expected. +#[test] +fn test_query_list_votes() { + let mut app = App::default(); + let mut instantiate = get_default_non_token_dao_proposal_module_instantiate(&mut app); + instantiate.threshold = Threshold::AbsoluteCount { + threshold: Uint128::new(3), + }; + instantiate.pre_propose_info = PreProposeInfo::AnyoneMayPropose {}; + let core_addr = instantiate_with_cw4_groups_governance( + &mut app, + instantiate, + Some(vec![ + Cw20Coin { + address: "one".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "two".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "three".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "four".to_string(), + amount: Uint128::new(1), + }, + Cw20Coin { + address: "five".to_string(), + amount: Uint128::new(1), + }, + ]), + ); + let proposal_module = query_single_proposal_module(&app, &core_addr); + let proposal_id = make_proposal(&mut app, &proposal_module, "one", vec![]); + + let votes = query_list_votes(&app, &proposal_module, proposal_id, None, None); + assert_eq!(votes.votes, vec![]); + + vote_on_proposal(&mut app, &proposal_module, "two", proposal_id, Vote::No); + vote_on_proposal(&mut app, &proposal_module, "three", proposal_id, Vote::No); + vote_on_proposal(&mut app, &proposal_module, "one", proposal_id, Vote::Yes); + vote_on_proposal(&mut app, &proposal_module, "four", proposal_id, Vote::Yes); + vote_on_proposal(&mut app, &proposal_module, "five", proposal_id, Vote::Yes); + + let votes = query_list_votes(&app, &proposal_module, proposal_id, None, None); + assert_eq!( + votes.votes, + vec![ + VoteInfo { + rationale: None, + voter: Addr::unchecked("five"), + vote: Vote::Yes, + power: Uint128::new(1) + }, + VoteInfo { + rationale: None, + voter: Addr::unchecked("four"), + vote: Vote::Yes, + power: Uint128::new(1) + }, + VoteInfo { + rationale: None, + voter: Addr::unchecked("one"), + vote: Vote::Yes, + power: Uint128::new(1) + }, + VoteInfo { + rationale: None, + voter: Addr::unchecked("three"), + vote: Vote::No, + power: Uint128::new(1) + }, + VoteInfo { + rationale: None, + voter: Addr::unchecked("two"), + vote: Vote::No, + power: Uint128::new(1) + } + ] + ); + + let votes = query_list_votes( + &app, + &proposal_module, + proposal_id, + Some("four".to_string()), + Some(2), + ); + assert_eq!( + votes.votes, + vec![ + VoteInfo { + rationale: None, + voter: Addr::unchecked("one"), + vote: Vote::Yes, + power: Uint128::new(1) + }, + VoteInfo { + rationale: None, + voter: Addr::unchecked("three"), + vote: Vote::No, + power: Uint128::new(1) + }, + ] + ); +} + +#[test] +fn test_update_pre_propose_module() { + let CommonTest { + mut app, + core_addr, + proposal_module, + gov_token, + proposal_id: pre_update_proposal_id, + } = setup_test(vec![]); + + // Store the address of the pre-propose module that we start with + // so we can execute withdraw on it later. + let proposal_creation_policy = query_creation_policy(&app, &proposal_module); + let pre_propose_start = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => panic!("expected a pre-propose module"), + ProposalCreationPolicy::Module { addr } => addr, + }; + + let pre_propose_id = app.store_code(pre_propose_single_contract()); + + // Make a proposal to switch to a new pre-propose moudle. + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + vec![WasmMsg::Execute { + contract_addr: proposal_module.to_string(), + msg: to_binary(&ExecuteMsg::UpdatePreProposeInfo { + info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_id, + msg: to_binary(&dao_pre_propose_single::InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: dao_voting::deposit::DepositToken::VotingModuleToken {}, + amount: Uint128::new(1), + refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "new pre-propose module".to_string(), + }, + }, + }) + .unwrap(), + funds: vec![], + } + .into()], + ); + + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + // Check that a new creation policy has been birthed. + let proposal_creation_policy = query_creation_policy(&app, &proposal_module); + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => panic!("expected a pre-propose module"), + ProposalCreationPolicy::Module { addr } => addr, + }; + + // Check that the admin has been set to the DAO properly. + let info: ContractInfoResponse = app + .wrap() + .query(&cosmwasm_std::QueryRequest::Wasm(WasmQuery::ContractInfo { + contract_addr: pre_propose.to_string(), + })) + .unwrap(); + assert_eq!(info.admin, Some(core_addr.to_string())); + + let pre_propose_config = query_pre_proposal_single_config(&app, &pre_propose); + assert_eq!( + pre_propose_config, + dao_pre_propose_single::Config { + deposit_info: Some(CheckedDepositInfo { + denom: CheckedDenom::Cw20(gov_token.clone()), + amount: Uint128::new(1), + refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + } + ); + + // Make a new proposal with this new module installed. + make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + // Check that the deposit was withdrawn. + let balance = query_balance_cw20(&app, gov_token.as_str(), CREATOR_ADDR); + assert_eq!(balance, Uint128::new(9_999_999)); + + // Vote on and execute the proposal created with the old + // module. This should work fine, but the deposit will not be + // returned as that module is no longer receiving hook messages. + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + pre_update_proposal_id, + Vote::Yes, + ); + execute_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + pre_update_proposal_id, + ); + + // Deposit should not have been returned. + let balance = query_balance_cw20(&app, gov_token.as_str(), CREATOR_ADDR); + assert_eq!(balance, Uint128::new(9_999_999)); + + // Withdraw from the old pre-propose module. + let proposal_id = make_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + vec![WasmMsg::Execute { + contract_addr: pre_propose_start.into_string(), + msg: to_binary(&dao_pre_propose_single::ExecuteMsg::Withdraw { denom: None }).unwrap(), + funds: vec![], + } + .into()], + ); + vote_on_proposal( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + ); + execute_proposal(&mut app, &proposal_module, CREATOR_ADDR, proposal_id); + + // Make sure the left over deposit was returned to the DAO. + let balance = query_balance_cw20(&app, gov_token.as_str(), core_addr.as_str()); + assert_eq!(balance, Uint128::new(10_000_000)); +} + +/// DAO should be admin of the pre-propose contract despite the fact +/// that the proposal module instantiates it. +#[test] +fn test_pre_propose_admin_is_dao() { + let CommonTest { + app, + core_addr, + proposal_module, + gov_token: _, + proposal_id: _, + } = setup_test(vec![]); + + let proposal_creation_policy = query_creation_policy(&app, &proposal_module); + + // Check that a new creation policy has been birthed. + let pre_propose = match proposal_creation_policy { + ProposalCreationPolicy::Anyone {} => panic!("expected a pre-propose module"), + ProposalCreationPolicy::Module { addr } => addr, + }; + + let info: ContractInfoResponse = app + .wrap() + .query(&cosmwasm_std::QueryRequest::Wasm(WasmQuery::ContractInfo { + contract_addr: pre_propose.into_string(), + })) + .unwrap(); + assert_eq!(info.admin, Some(core_addr.into_string())); +} + +// I can add a rationale to my vote. My rational is queryable when +// listing votes. I can later change my rationale. +#[test] +fn test_rationale() { + let CommonTest { + mut app, + proposal_module, + proposal_id, + .. + } = setup_test(vec![]); + + let rationale = Some("i support dog charities".to_string()); + + vote_on_proposal_with_rationale( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + rationale.clone(), + ); + + let vote = query_vote(&app, &proposal_module, CREATOR_ADDR, proposal_id); + assert_eq!(vote.vote.unwrap().rationale, rationale); + + let rationale = + Some("i did not realize that dog charity was gambling with customer funds".to_string()); + + update_rationale( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + rationale.clone(), + ); + + let vote = query_vote(&app, &proposal_module, CREATOR_ADDR, proposal_id); + assert_eq!(vote.vote.unwrap().rationale, rationale); +} + +// Revoting should override any previous rationale. If no new +// rationalle is provided, the old one will be wiped regardless. +#[test] +fn test_rational_clobbered_on_revote() { + let mut app = App::default(); + let mut instantiate = get_default_token_dao_proposal_module_instantiate(&mut app); + instantiate.allow_revoting = true; + let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); + let gov_token = query_dao_token(&app, &core_addr); + let proposal_module = query_single_proposal_module(&app, &core_addr); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + let proposal_id = make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + let rationale = Some("to_string".to_string()); + + vote_on_proposal_with_rationale( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::Yes, + rationale.clone(), + ); + + let vote = query_vote(&app, &proposal_module, CREATOR_ADDR, proposal_id); + assert_eq!(vote.vote.unwrap().rationale, rationale); + + let rationale = None; + + // revote and clobber. + vote_on_proposal_with_rationale( + &mut app, + &proposal_module, + CREATOR_ADDR, + proposal_id, + Vote::No, + None, + ); + + let vote = query_vote(&app, &proposal_module, CREATOR_ADDR, proposal_id); + assert_eq!(vote.vote.unwrap().rationale, rationale); +} + +// Casting votes is only allowed within the proposal expiration timeframe +#[test] +pub fn test_not_allow_voting_on_expired_proposal() { + let CommonTest { + mut app, + core_addr: _, + proposal_module, + gov_token: _, + proposal_id, + } = setup_test(vec![]); + + // expire the proposal + app.update_block(|b| b.time = b.time.plus_seconds(604800)); + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Rejected); + assert_eq!(proposal.proposal.votes.yes, Uint128::zero()); + + // attempt to vote past the expiration date + let err: ContractError = app + .execute_contract( + Addr::unchecked(CREATOR_ADDR), + proposal_module.clone(), + &ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + rationale: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + // assert the vote got rejected and did not count + // towards the votes + let proposal = query_proposal(&app, &proposal_module, proposal_id); + assert_eq!(proposal.proposal.status, Status::Rejected); + assert_eq!(proposal.proposal.votes.yes, Uint128::zero()); + assert!(matches!(err, ContractError::Expired { id: _proposal_id })); +} + +#[test] +fn test_proposal_count_goes_up() { + let CommonTest { + mut app, + proposal_module, + gov_token, + core_addr, + .. + } = setup_test(vec![]); + + let next = query_next_proposal_id(&app, &proposal_module); + assert_eq!(next, 2); + + mint_cw20s(&mut app, &gov_token, &core_addr, CREATOR_ADDR, 10_000_000); + make_proposal(&mut app, &proposal_module, CREATOR_ADDR, vec![]); + + let next = query_next_proposal_id(&app, &proposal_module); + assert_eq!(next, 3); +} diff --git a/contracts/proposal/dao-proposal-single/src/v1_state.rs b/contracts/proposal/dao-proposal-single/src/v1_state.rs new file mode 100644 index 000000000..9533347c8 --- /dev/null +++ b/contracts/proposal/dao-proposal-single/src/v1_state.rs @@ -0,0 +1,163 @@ +//! Helper methods for migrating from v1 to v2 state. These will need +//! to be updated when we bump our CosmWasm version for v2. + +use cw_utils::{Duration, Expiration}; +use dao_voting::{ + status::Status, + threshold::{PercentageThreshold, Threshold}, + voting::Votes, +}; + +pub fn v1_percentage_threshold_to_v2(v1: voting_v1::PercentageThreshold) -> PercentageThreshold { + match v1 { + voting_v1::PercentageThreshold::Majority {} => PercentageThreshold::Majority {}, + voting_v1::PercentageThreshold::Percent(p) => PercentageThreshold::Percent(p), + } +} + +pub fn v1_threshold_to_v2(v1: voting_v1::Threshold) -> Threshold { + match v1 { + voting_v1::Threshold::AbsolutePercentage { percentage } => Threshold::AbsolutePercentage { + percentage: v1_percentage_threshold_to_v2(percentage), + }, + voting_v1::Threshold::ThresholdQuorum { threshold, quorum } => Threshold::ThresholdQuorum { + threshold: v1_percentage_threshold_to_v2(threshold), + quorum: v1_percentage_threshold_to_v2(quorum), + }, + voting_v1::Threshold::AbsoluteCount { threshold } => Threshold::AbsoluteCount { threshold }, + } +} + +pub fn v1_duration_to_v2(v1: cw_utils_v1::Duration) -> Duration { + match v1 { + cw_utils_v1::Duration::Height(height) => Duration::Height(height), + cw_utils_v1::Duration::Time(time) => Duration::Time(time), + } +} + +pub fn v1_expiration_to_v2(v1: cw_utils_v1::Expiration) -> Expiration { + match v1 { + cw_utils_v1::Expiration::AtHeight(height) => Expiration::AtHeight(height), + cw_utils_v1::Expiration::AtTime(time) => Expiration::AtTime(time), + cw_utils_v1::Expiration::Never {} => Expiration::Never {}, + } +} + +pub fn v1_votes_to_v2(v1: voting_v1::Votes) -> Votes { + Votes { + yes: v1.yes, + no: v1.no, + abstain: v1.abstain, + } +} + +pub fn v1_status_to_v2(v1: voting_v1::Status) -> Status { + match v1 { + voting_v1::Status::Open => Status::Open, + voting_v1::Status::Rejected => Status::Rejected, + voting_v1::Status::Passed => Status::Passed, + voting_v1::Status::Executed => Status::Executed, + voting_v1::Status::Closed => Status::Closed, + } +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::{Decimal, Timestamp, Uint128}; + + use super::*; + + #[test] + fn test_percentage_conversion() { + assert_eq!( + v1_percentage_threshold_to_v2(voting_v1::PercentageThreshold::Majority {}), + PercentageThreshold::Majority {} + ); + assert_eq!( + v1_percentage_threshold_to_v2(voting_v1::PercentageThreshold::Percent( + Decimal::percent(80) + )), + PercentageThreshold::Percent(Decimal::percent(80)) + ) + } + + #[test] + fn test_duration_conversion() { + assert_eq!( + v1_duration_to_v2(cw_utils_v1::Duration::Height(100)), + Duration::Height(100) + ); + assert_eq!( + v1_duration_to_v2(cw_utils_v1::Duration::Time(100)), + Duration::Time(100) + ); + } + + #[test] + fn test_expiration_conversion() { + assert_eq!( + v1_expiration_to_v2(cw_utils_v1::Expiration::AtHeight(100)), + Expiration::AtHeight(100) + ); + assert_eq!( + v1_expiration_to_v2(cw_utils_v1::Expiration::AtTime(Timestamp::from_seconds( + 100 + ))), + Expiration::AtTime(Timestamp::from_seconds(100)) + ); + assert_eq!( + v1_expiration_to_v2(cw_utils_v1::Expiration::Never {}), + Expiration::Never {} + ); + } + + #[test] + fn test_threshold_conversion() { + assert_eq!( + v1_threshold_to_v2(voting_v1::Threshold::AbsoluteCount { + threshold: Uint128::new(10) + }), + Threshold::AbsoluteCount { + threshold: Uint128::new(10) + } + ); + assert_eq!( + v1_threshold_to_v2(voting_v1::Threshold::AbsolutePercentage { + percentage: voting_v1::PercentageThreshold::Majority {} + }), + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {} + } + ); + assert_eq!( + v1_threshold_to_v2(voting_v1::Threshold::ThresholdQuorum { + threshold: voting_v1::PercentageThreshold::Majority {}, + quorum: voting_v1::PercentageThreshold::Percent(Decimal::percent(20)) + }), + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(20)) + } + ); + } + + #[test] + fn test_status_conversion() { + macro_rules! status_conversion { + ($x:expr) => { + assert_eq!( + v1_status_to_v2({ + use voting_v1::Status; + $x + }), + $x + ) + }; + } + + status_conversion!(Status::Open); + status_conversion!(Status::Closed); + status_conversion!(Status::Executed); + status_conversion!(Status::Rejected) + } +} diff --git a/contracts/staking/cw20-stake-external-rewards/.cargo/config b/contracts/staking/cw20-stake-external-rewards/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/staking/cw20-stake-external-rewards/Cargo.toml b/contracts/staking/cw20-stake-external-rewards/Cargo.toml new file mode 100644 index 000000000..1a7dd7cf1 --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "cw20-stake-external-rewards" +authors = ["Ben2x4 ", "ekez "] +edition = "2018" +description = "Distributes staking rewards." +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 } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +cw20 = { workspace = true } +cw-utils = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw2 = { workspace = true } +thiserror = { workspace = true } +cw20-stake = { workspace = true, features = ["library"]} +cw-ownable = { workspace = true } + +cw20-stake-external-rewards-v1 = { workspace = true } +cw20-013 = { package = "cw20", version = "0.13" } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = { workspace = true } diff --git a/contracts/staking/cw20-stake-external-rewards/README.md b/contracts/staking/cw20-stake-external-rewards/README.md new file mode 100644 index 000000000..34128376d --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/README.md @@ -0,0 +1,4 @@ +# CW20 Stake External Rewards + +This contract enables staking rewards in terms of non-governance +tokens. diff --git a/contracts/staking/cw20-stake-external-rewards/examples/schema.rs b/contracts/staking/cw20-stake-external-rewards/examples/schema.rs new file mode 100644 index 000000000..ca1d1c887 --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use cw20_stake_external_rewards::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} 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 new file mode 100644 index 000000000..af3bc2f44 --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/schema/cw20-stake-external-rewards.json @@ -0,0 +1,705 @@ +{ + "contract_name": "cw20-stake-external-rewards", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "reward_duration", + "reward_token", + "staking_contract" + ], + "properties": { + "owner": { + "type": [ + "string", + "null" + ] + }, + "reward_duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "reward_token": { + "$ref": "#/definitions/Denom" + }, + "staking_contract": { + "type": "string" + } + }, + "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 + } + ] + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "stake_change_hook" + ], + "properties": { + "stake_change_hook": { + "$ref": "#/definitions/StakeChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "fund" + ], + "properties": { + "fund": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_reward_duration" + ], + "properties": { + "update_reward_duration": { + "type": "object", + "required": [ + "new_duration" + ], + "properties": { + "new_duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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 + }, + "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 + } + ] + }, + "StakeChangedHookMsg": { + "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" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_pending_rewards" + ], + "properties": { + "get_pending_rewards": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "description": "Migrates from version 0.2.6 to 2.0.0. The significant changes being the addition of a two-step ownership transfer using `cw_ownable` and the removal of the manager. Migrating will automatically remove the current manager.", + "type": "object", + "required": [ + "from_v1" + ], + "properties": { + "from_v1": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "sudo": null, + "responses": { + "get_pending_rewards": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingRewardsResponse", + "type": "object", + "required": [ + "address", + "denom", + "last_update_block", + "pending_rewards" + ], + "properties": { + "address": { + "type": "string" + }, + "denom": { + "$ref": "#/definitions/Denom" + }, + "last_update_block": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "pending_rewards": { + "$ref": "#/definitions/Uint128" + } + }, + "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 + } + ] + }, + "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": [ + "config", + "reward" + ], + "properties": { + "config": { + "$ref": "#/definitions/Config" + }, + "reward": { + "$ref": "#/definitions/RewardConfig" + } + }, + "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" + }, + "Config": { + "type": "object", + "required": [ + "reward_token", + "staking_contract" + ], + "properties": { + "reward_token": { + "$ref": "#/definitions/Denom" + }, + "staking_contract": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "RewardConfig": { + "type": "object", + "required": [ + "period_finish", + "reward_duration", + "reward_rate" + ], + "properties": { + "period_finish": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "reward_duration": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "reward_rate": { + "$ref": "#/definitions/Uint128" + } + }, + "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" + } + } + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_Addr", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "pending_expiry": { + "description": "The deadline for the pending owner to accept the ownership. `None` if there isn't a pending ownership transfer, or if a transfer exists and it doesn't have a deadline.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "pending_owner": { + "description": "The account who has been proposed to take over the ownership. `None` if there isn't a pending ownership transfer.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + } + } +} diff --git a/contracts/staking/cw20-stake-external-rewards/src/contract.rs b/contracts/staking/cw20-stake-external-rewards/src/contract.rs new file mode 100644 index 000000000..a2a50412d --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/src/contract.rs @@ -0,0 +1,2037 @@ +use crate::msg::{ + ExecuteMsg, InfoResponse, InstantiateMsg, MigrateMsg, PendingRewardsResponse, QueryMsg, + ReceiveMsg, +}; +use crate::state::{ + Config, RewardConfig, CONFIG, LAST_UPDATE_BLOCK, PENDING_REWARDS, REWARD_CONFIG, + REWARD_PER_TOKEN, USER_REWARD_PER_TOKEN, +}; +use crate::ContractError; +use crate::ContractError::{ + InvalidCw20, InvalidFunds, NoRewardsClaimable, RewardPeriodNotFinished, +}; +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; + +use cosmwasm_std::{ + from_binary, to_binary, Addr, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, Empty, Env, + MessageInfo, Response, StdError, StdResult, Uint128, Uint256, WasmMsg, +}; +use cw2::{get_contract_version, set_contract_version, ContractVersion}; +use cw20::{Cw20ReceiveMsg, Denom}; +use cw20_stake::hooks::StakeChangedHookMsg; + +use cw20::Denom::Cw20; +use std::cmp::min; +use std::convert::TryInto; + +const CONTRACT_NAME: &str = "crates.io:cw20-stake-external-rewards"; +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, ContractError> { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + + let reward_token = match msg.reward_token { + Denom::Native(denom) => Denom::Native(denom), + Cw20(addr) => Cw20(deps.api.addr_validate(addr.as_ref())?), + }; + + // Verify contract provided is a staking contract + let _: cw20_stake::msg::TotalStakedAtHeightResponse = deps.querier.query_wasm_smart( + &msg.staking_contract, + &cw20_stake::msg::QueryMsg::TotalStakedAtHeight { height: None }, + )?; + + let config = Config { + staking_contract: deps.api.addr_validate(&msg.staking_contract)?, + reward_token, + }; + CONFIG.save(deps.storage, &config)?; + + if msg.reward_duration == 0 { + return Err(ContractError::ZeroRewardDuration {}); + } + + let reward_config = RewardConfig { + period_finish: 0, + reward_rate: Uint128::zero(), + reward_duration: msg.reward_duration, + }; + REWARD_CONFIG.save(deps.storage, &reward_config)?; + + Ok(Response::new() + .add_attribute("owner", msg.owner.unwrap_or_else(|| "None".to_string())) + .add_attribute("staking_contract", config.staking_contract) + .add_attribute( + "reward_token", + match config.reward_token { + Denom::Native(denom) => denom, + Cw20(addr) => addr.into_string(), + }, + ) + .add_attribute("reward_rate", reward_config.reward_rate) + .add_attribute("period_finish", reward_config.period_finish.to_string()) + .add_attribute("reward_duration", reward_config.reward_duration.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + use cw20_stake_external_rewards_v1 as v1; + + let ContractVersion { version, .. } = get_contract_version(deps.storage)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + match msg { + MigrateMsg::FromV1 {} => { + if version == CONTRACT_VERSION { + // You can not possibly be migrating from v1 to v2 and + // also not changing your contract version. + return Err(ContractError::AlreadyMigrated {}); + } + // From v1 -> v2 we moved `owner` out of config and into + // the `cw_ownable` package. + let config = v1::state::CONFIG.load(deps.storage)?; + cw_ownable::initialize_owner( + deps.storage, + deps.api, + config.owner.map(|a| a.into_string()).as_deref(), + )?; + let config = Config { + staking_contract: config.staking_contract, + reward_token: match config.reward_token { + cw20_013::Denom::Native(n) => Denom::Native(n), + cw20_013::Denom::Cw20(a) => Denom::Cw20(a), + }, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default()) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), + ExecuteMsg::Claim {} => execute_claim(deps, env, info), + ExecuteMsg::Fund {} => execute_fund_native(deps, env, info), + ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), + ExecuteMsg::UpdateRewardDuration { new_duration } => { + execute_update_reward_duration(deps, env, info, new_duration) + } + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + } +} + +pub fn execute_receive( + deps: DepsMut, + env: Env, + info: MessageInfo, + wrapper: Cw20ReceiveMsg, +) -> Result, ContractError> { + let msg: ReceiveMsg = from_binary(&wrapper.msg)?; + let config = CONFIG.load(deps.storage)?; + let sender = deps.api.addr_validate(&wrapper.sender)?; + if config.reward_token != Denom::Cw20(info.sender) { + return Err(InvalidCw20 {}); + }; + match msg { + ReceiveMsg::Fund {} => execute_fund(deps, env, sender, wrapper.amount), + } +} + +pub fn execute_fund_native( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result, ContractError> { + let config = CONFIG.load(deps.storage)?; + + match config.reward_token { + Denom::Native(denom) => { + let amount = cw_utils::must_pay(&info, &denom).map_err(|_| InvalidFunds {})?; + execute_fund(deps, env, info.sender, amount) + } + Cw20(_) => Err(InvalidFunds {}), + } +} + +pub fn execute_fund( + mut deps: DepsMut, + env: Env, + sender: Addr, + amount: Uint128, +) -> Result, ContractError> { + cw_ownable::assert_owner(deps.storage, &sender)?; + + update_rewards(&mut deps, &env, &sender)?; + let reward_config = REWARD_CONFIG.load(deps.storage)?; + if reward_config.period_finish > env.block.height { + return Err(RewardPeriodNotFinished {}); + } + let new_reward_config = RewardConfig { + period_finish: env.block.height + reward_config.reward_duration, + reward_rate: amount + .checked_div(Uint128::from(reward_config.reward_duration)) + .map_err(StdError::divide_by_zero)?, + // As we're not changing the value and changing the value + // validates that the duration is non-zero we don't need to + // check here. + reward_duration: reward_config.reward_duration, + }; + + if new_reward_config.reward_rate == Uint128::zero() { + return Err(ContractError::RewardRateLessThenOnePerBlock {}); + }; + + REWARD_CONFIG.save(deps.storage, &new_reward_config)?; + LAST_UPDATE_BLOCK.save(deps.storage, &env.block.height)?; + + Ok(Response::new() + .add_attribute("action", "fund") + .add_attribute("amount", amount) + .add_attribute("new_reward_rate", new_reward_config.reward_rate.to_string())) +} + +pub fn execute_stake_changed( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: StakeChangedHookMsg, +) -> Result, ContractError> { + let config = CONFIG.load(deps.storage)?; + if info.sender != config.staking_contract { + return Err(ContractError::InvalidHookSender {}); + }; + match msg { + StakeChangedHookMsg::Stake { addr, .. } => execute_stake(deps, env, addr), + StakeChangedHookMsg::Unstake { addr, .. } => execute_unstake(deps, env, addr), + } +} + +pub fn execute_stake( + mut deps: DepsMut, + env: Env, + addr: Addr, +) -> Result, ContractError> { + update_rewards(&mut deps, &env, &addr)?; + Ok(Response::new().add_attribute("action", "stake")) +} + +pub fn execute_unstake( + mut deps: DepsMut, + env: Env, + addr: Addr, +) -> Result, ContractError> { + update_rewards(&mut deps, &env, &addr)?; + Ok(Response::new().add_attribute("action", "unstake")) +} + +pub fn execute_claim( + mut deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result, ContractError> { + update_rewards(&mut deps, &env, &info.sender)?; + let rewards = PENDING_REWARDS + .load(deps.storage, info.sender.clone()) + .map_err(|_| NoRewardsClaimable {})?; + if rewards == Uint128::zero() { + return Err(ContractError::NoRewardsClaimable {}); + } + PENDING_REWARDS.save(deps.storage, info.sender.clone(), &Uint128::zero())?; + let config = CONFIG.load(deps.storage)?; + let transfer_msg = get_transfer_msg(info.sender, rewards, config.reward_token)?; + Ok(Response::new() + .add_message(transfer_msg) + .add_attribute("action", "claim") + .add_attribute("amount", rewards)) +} + +pub fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +pub fn get_transfer_msg(recipient: Addr, amount: Uint128, denom: Denom) -> StdResult { + match denom { + Denom::Native(denom) => Ok(BankMsg::Send { + to_address: recipient.into_string(), + amount: vec![Coin { denom, amount }], + } + .into()), + Denom::Cw20(addr) => { + let cw20_msg = to_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: recipient.into_string(), + amount, + })?; + Ok(WasmMsg::Execute { + contract_addr: addr.into_string(), + msg: cw20_msg, + funds: vec![], + } + .into()) + } + } +} + +pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr) -> StdResult<()> { + let config = CONFIG.load(deps.storage)?; + let reward_per_token = get_reward_per_token(deps.as_ref(), env, &config.staking_contract)?; + REWARD_PER_TOKEN.save(deps.storage, &reward_per_token)?; + + let earned_rewards = get_rewards_earned( + deps.as_ref(), + env, + addr, + reward_per_token, + &config.staking_contract, + )?; + PENDING_REWARDS.update::<_, StdError>(deps.storage, addr.clone(), |r| { + Ok(r.unwrap_or_default() + earned_rewards) + })?; + + USER_REWARD_PER_TOKEN.save(deps.storage, addr.clone(), &reward_per_token)?; + let last_time_reward_applicable = get_last_time_reward_applicable(deps.as_ref(), env)?; + LAST_UPDATE_BLOCK.save(deps.storage, &last_time_reward_applicable)?; + Ok(()) +} + +pub fn get_reward_per_token(deps: Deps, env: &Env, staking_contract: &Addr) -> StdResult { + let reward_config = REWARD_CONFIG.load(deps.storage)?; + let total_staked = get_total_staked(deps, staking_contract)?; + let last_time_reward_applicable = get_last_time_reward_applicable(deps, env)?; + let last_update_block = LAST_UPDATE_BLOCK.load(deps.storage).unwrap_or_default(); + let prev_reward_per_token = REWARD_PER_TOKEN.load(deps.storage).unwrap_or_default(); + let additional_reward_per_token = if total_staked == Uint128::zero() { + Uint256::zero() + } else { + // 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 + let numerator = reward_config + .reward_rate + .full_mul(Uint128::from( + last_time_reward_applicable - last_update_block, + )) + .checked_mul(scale_factor())?; + let denominator = Uint256::from(total_staked); + numerator.checked_div(denominator)? + }; + + Ok(prev_reward_per_token + additional_reward_per_token) +} + +pub fn get_rewards_earned( + deps: Deps, + _env: &Env, + addr: &Addr, + reward_per_token: Uint256, + staking_contract: &Addr, +) -> StdResult { + let _config = CONFIG.load(deps.storage)?; + let staked_balance = Uint256::from(get_staked_balance(deps, staking_contract, addr)?); + let user_reward_per_token = USER_REWARD_PER_TOKEN + .load(deps.storage, addr.clone()) + .unwrap_or_default(); + let reward_factor = reward_per_token.checked_sub(user_reward_per_token)?; + Ok(staked_balance + .checked_mul(reward_factor)? + .checked_div(scale_factor())? + .try_into()?) +} + +fn get_last_time_reward_applicable(deps: Deps, env: &Env) -> StdResult { + let reward_config = REWARD_CONFIG.load(deps.storage)?; + Ok(min(env.block.height, reward_config.period_finish)) +} + +fn get_total_staked(deps: Deps, contract_addr: &Addr) -> StdResult { + let msg = cw20_stake::msg::QueryMsg::TotalStakedAtHeight { height: None }; + let resp: cw20_stake::msg::TotalStakedAtHeightResponse = + deps.querier.query_wasm_smart(contract_addr, &msg)?; + Ok(resp.total) +} + +fn get_staked_balance(deps: Deps, contract_addr: &Addr, addr: &Addr) -> StdResult { + let msg = cw20_stake::msg::QueryMsg::StakedBalanceAtHeight { + address: addr.into(), + height: None, + }; + let resp: cw20_stake::msg::StakedBalanceAtHeightResponse = + deps.querier.query_wasm_smart(contract_addr, &msg)?; + Ok(resp.balance) +} + +pub fn execute_update_reward_duration( + deps: DepsMut, + env: Env, + info: MessageInfo, + new_duration: u64, +) -> Result, ContractError> { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut reward_config = REWARD_CONFIG.load(deps.storage)?; + if reward_config.period_finish > env.block.height { + return Err(ContractError::RewardPeriodNotFinished {}); + }; + + if new_duration == 0 { + return Err(ContractError::ZeroRewardDuration {}); + } + + let old_duration = reward_config.reward_duration; + reward_config.reward_duration = new_duration; + REWARD_CONFIG.save(deps.storage, &reward_config)?; + + Ok(Response::new() + .add_attribute("action", "update_reward_duration") + .add_attribute("new_duration", new_duration.to_string()) + .add_attribute("old_duration", old_duration.to_string())) +} + +fn scale_factor() -> Uint256 { + Uint256::from(10u8).pow(39) +} +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Info {} => Ok(to_binary(&query_info(deps, env)?)?), + QueryMsg::GetPendingRewards { address } => { + Ok(to_binary(&query_pending_rewards(deps, env, address)?)?) + } + QueryMsg::Ownership {} => to_binary(&cw_ownable::get_ownership(deps.storage)?), + } +} + +pub fn query_info(deps: Deps, _env: Env) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let reward = REWARD_CONFIG.load(deps.storage)?; + Ok(InfoResponse { config, reward }) +} + +pub fn query_pending_rewards( + deps: Deps, + env: Env, + addr: String, +) -> StdResult { + let addr = deps.api.addr_validate(&addr)?; + let config = CONFIG.load(deps.storage)?; + let reward_per_token = get_reward_per_token(deps, &env, &config.staking_contract)?; + let earned_rewards = get_rewards_earned( + deps, + &env, + &addr, + reward_per_token, + &config.staking_contract, + )?; + + let existing_rewards = PENDING_REWARDS + .load(deps.storage, addr.clone()) + .unwrap_or_default(); + let pending_rewards = earned_rewards + existing_rewards; + Ok(PendingRewardsResponse { + address: addr.to_string(), + pending_rewards, + denom: config.reward_token, + last_update_block: LAST_UPDATE_BLOCK.load(deps.storage).unwrap_or_default(), + }) +} + +#[cfg(test)] +mod tests { + use std::borrow::BorrowMut; + + use crate::{msg::MigrateMsg, ContractError}; + + use cosmwasm_std::{coin, to_binary, Addr, Empty, Uint128, WasmMsg}; + use cw20::{Cw20Coin, Cw20ExecuteMsg, Denom}; + use cw_ownable::{Action, Ownership, OwnershipError}; + use cw_utils::Duration; + + use cw_multi_test::{next_block, App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; + + use cw20_stake_external_rewards_v1 as v1; + + use crate::msg::{ExecuteMsg, InfoResponse, PendingRewardsResponse, QueryMsg, ReceiveMsg}; + + const OWNER: &str = "owner"; + const ADDR1: &str = "addr0001"; + const ADDR2: &str = "addr0002"; + const ADDR3: &str = "addr0003"; + + pub fn contract_rewards() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate); + Box::new(contract) + } + + pub fn contract_rewards_v1() -> Box> { + let contract = ContractWrapper::new( + v1::contract::execute, + v1::contract::instantiate, + v1::contract::query, + ); + Box::new(contract) + } + + pub fn contract_staking() -> Box> { + let contract = ContractWrapper::new( + cw20_stake::contract::execute, + cw20_stake::contract::instantiate, + cw20_stake::contract::query, + ); + Box::new(contract) + } + + pub fn contract_cw20() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) + } + + fn mock_app() -> App { + App::default() + } + + fn instantiate_cw20(app: &mut App, initial_balances: Vec) -> Addr { + let cw20_id = app.store_code(contract_cw20()); + let msg = cw20_base::msg::InstantiateMsg { + name: String::from("Test"), + symbol: String::from("TEST"), + decimals: 6, + initial_balances, + mint: None, + marketing: None, + }; + + app.instantiate_contract(cw20_id, Addr::unchecked(ADDR1), &msg, &[], "cw20", None) + .unwrap() + } + + fn instantiate_staking( + app: &mut App, + cw20: Addr, + unstaking_duration: Option, + ) -> Addr { + let staking_code_id = app.store_code(contract_staking()); + 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(ADDR1), + &msg, + &[], + "staking", + None, + ) + .unwrap() + } + + fn stake_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_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(sender), cw20_addr.clone(), &msg, &[]) + .unwrap(); + } + + fn unstake_tokens(app: &mut App, staking_addr: &Addr, address: &str, amount: u128) { + let msg = cw20_stake::msg::ExecuteMsg::Unstake { + amount: Uint128::new(amount), + }; + app.execute_contract(Addr::unchecked(address), staking_addr.clone(), &msg, &[]) + .unwrap(); + } + + fn setup_staking_contract(app: &mut App, initial_balances: Vec) -> (Addr, Addr) { + // Instantiate cw20 contract + let cw20_addr = instantiate_cw20(app, initial_balances.clone()); + app.update_block(next_block); + // Instantiate staking contract + let staking_addr = instantiate_staking(app, cw20_addr.clone(), None); + app.update_block(next_block); + for coin in initial_balances { + stake_tokens( + app, + &staking_addr, + &cw20_addr, + coin.address, + coin.amount.u128(), + ); + } + (staking_addr, cw20_addr) + } + + fn setup_reward_contract( + app: &mut App, + staking_contract: Addr, + reward_token: Denom, + owner: Addr, + ) -> Addr { + let reward_code_id = app.store_code(contract_rewards()); + let msg = crate::msg::InstantiateMsg { + owner: Some(owner.clone().into_string()), + staking_contract: staking_contract.clone().into_string(), + reward_token, + reward_duration: 100000, + }; + let reward_addr = app + .instantiate_contract(reward_code_id, owner, &msg, &[], "reward", None) + .unwrap(); + let msg = cw20_stake::msg::ExecuteMsg::AddHook { + addr: reward_addr.to_string(), + }; + let _result = app + .execute_contract(Addr::unchecked(OWNER), staking_contract, &msg, &[]) + .unwrap(); + reward_addr + } + + fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, + ) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = + app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance + } + + fn get_balance_native, U: Into>( + app: &App, + address: T, + denom: U, + ) -> Uint128 { + app.wrap().query_balance(address, denom).unwrap().amount + } + + fn get_ownership>(app: &App, address: T) -> Ownership { + app.wrap() + .query_wasm_smart(address, &QueryMsg::Ownership {}) + .unwrap() + } + + fn assert_pending_rewards(app: &mut App, reward_addr: &Addr, address: &str, expected: u128) { + let res: PendingRewardsResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart( + reward_addr, + &QueryMsg::GetPendingRewards { + address: address.to_string(), + }, + ) + .unwrap(); + assert_eq!(res.pending_rewards, Uint128::new(expected)); + } + + fn claim_rewards(app: &mut App, reward_addr: Addr, address: &str) { + let msg = ExecuteMsg::Claim {}; + app.borrow_mut() + .execute_contract(Addr::unchecked(address), reward_addr, &msg, &[]) + .unwrap(); + } + + fn fund_rewards_cw20( + app: &mut App, + admin: &Addr, + reward_token: Addr, + reward_addr: &Addr, + amount: u128, + ) { + let fund_sub_msg = to_binary(&ReceiveMsg::Fund {}).unwrap(); + let fund_msg = Cw20ExecuteMsg::Send { + contract: reward_addr.clone().into_string(), + amount: Uint128::new(amount), + msg: fund_sub_msg, + }; + let _res = app + .borrow_mut() + .execute_contract(admin.clone(), reward_token, &fund_msg, &[]) + .unwrap(); + } + + #[test] + fn test_zero_rewards_duration() { + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + let denom = "utest".to_string(); + let (staking_addr, _) = setup_staking_contract(&mut app, vec![]); + let reward_funding = vec![coin(100000000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding, + } + })) + .unwrap(); + + let reward_token = Denom::Native(denom); + let owner = admin; + let reward_code_id = app.store_code(contract_rewards()); + let msg = crate::msg::InstantiateMsg { + owner: Some(owner.clone().into_string()), + staking_contract: staking_addr.to_string(), + reward_token, + reward_duration: 0, + }; + let err: ContractError = app + .instantiate_contract(reward_code_id, owner, &msg, &[], "reward", None) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::ZeroRewardDuration {}) + } + + #[test] + fn test_native_rewards() { + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + let (staking_addr, cw20_addr) = setup_staking_contract(&mut app, initial_balances); + let reward_funding = vec![coin(100000000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + let reward_addr = setup_reward_contract( + &mut app, + staking_addr.clone(), + Denom::Native(denom.clone()), + admin.clone(), + ); + + app.borrow_mut().update_block(|b| b.height = 1000); + + let fund_msg = ExecuteMsg::Fund {}; + + let _res = app + .borrow_mut() + .execute_contract( + admin.clone(), + reward_addr.clone(), + &fund_msg, + &reward_funding, + ) + .unwrap(); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(1000)); + assert_eq!(res.reward.period_finish, 101000); + assert_eq!(res.reward.reward_duration, 100000); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 500); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 250); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 250); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 1000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 500); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 1500); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 750); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 750); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 2000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 1000); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 1000); + + assert_eq!(get_balance_native(&app, ADDR1, &denom), Uint128::zero()); + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + assert_eq!(get_balance_native(&app, ADDR1, &denom), Uint128::new(2000)); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 0); + + app.borrow_mut().update_block(|b| b.height += 10); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 5000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 3500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 3500); + + unstake_tokens(&mut app, &staking_addr, ADDR2, 50); + unstake_tokens(&mut app, &staking_addr, ADDR3, 50); + + app.borrow_mut().update_block(|b| b.height += 10); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 15000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 3500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 3500); + + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + assert_eq!(get_balance_native(&app, ADDR1, &denom), Uint128::new(17000)); + + claim_rewards(&mut app, reward_addr.clone(), ADDR2); + assert_eq!(get_balance_native(&app, ADDR2, &denom), Uint128::new(3500)); + + stake_tokens(&mut app, &staking_addr, &cw20_addr, ADDR2, 50); + stake_tokens(&mut app, &staking_addr, &cw20_addr, ADDR3, 50); + + app.borrow_mut().update_block(|b| b.height += 10); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 5000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 2500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 6000); + + // Current height is 1034. ADDR1 is receiving 500 tokens/block + // and ADDR2 / ADDR3 are receiving 250. + // + // At height 101000 99966 additional blocks have passed. So we + // expect: + // + // ADDR1: 5000 + 99966 * 500 = 49,998,000 + // ADDR2: 2500 + 99966 * 250 = 24,994,000 + // ADDR3: 6000 + 99966 * 250 = 24,997,500 + app.borrow_mut().update_block(|b| b.height = 101000); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 49988000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 24994000); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 24997500); + + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + claim_rewards(&mut app, reward_addr.clone(), ADDR2); + assert_eq!( + get_balance_native(&app, ADDR1, &denom), + Uint128::new(50005000) + ); + assert_eq!( + get_balance_native(&app, ADDR2, &denom), + Uint128::new(24997500) + ); + assert_eq!(get_balance_native(&app, ADDR3, &denom), Uint128::new(0)); + assert_eq!( + get_balance_native(&app, &reward_addr, &denom), + Uint128::new(24997500) + ); + + app.borrow_mut().update_block(|b| b.height = 200000); + let fund_msg = ExecuteMsg::Fund {}; + + // Add more rewards + let reward_funding = vec![coin(200000000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + + let _res = app + .borrow_mut() + .execute_contract( + admin.clone(), + reward_addr.clone(), + &fund_msg, + &reward_funding, + ) + .unwrap(); + + app.borrow_mut().update_block(|b| b.height = 300000); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 100000000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 50000000); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 74997500); + + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + claim_rewards(&mut app, reward_addr.clone(), ADDR2); + assert_eq!( + get_balance_native(&app, ADDR1, &denom), + Uint128::new(150005000) + ); + assert_eq!( + get_balance_native(&app, ADDR2, &denom), + Uint128::new(74997500) + ); + assert_eq!(get_balance_native(&app, ADDR3, &denom), Uint128::zero()); + assert_eq!( + get_balance_native(&app, &reward_addr, &denom), + Uint128::new(74997500) + ); + + // Add more rewards + let reward_funding = vec![coin(200000000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + + let _res = app + .borrow_mut() + .execute_contract(admin, reward_addr.clone(), &fund_msg, &reward_funding) + .unwrap(); + + app.borrow_mut().update_block(|b| b.height = 400000); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 100000000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 50000000); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 124997500); + + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + claim_rewards(&mut app, reward_addr.clone(), ADDR2); + claim_rewards(&mut app, reward_addr.clone(), ADDR3); + assert_eq!( + get_balance_native(&app, ADDR1, &denom), + Uint128::new(250005000) + ); + assert_eq!( + get_balance_native(&app, ADDR2, &denom), + Uint128::new(124997500) + ); + assert_eq!( + get_balance_native(&app, ADDR3, &denom), + Uint128::new(124997500) + ); + assert_eq!( + get_balance_native(&app, &reward_addr, &denom), + Uint128::zero() + ); + + app.borrow_mut().update_block(|b| b.height = 500000); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 0); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 0); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 0); + + app.borrow_mut().update_block(|b| b.height = 1000000); + unstake_tokens(&mut app, &staking_addr, ADDR3, 1); + stake_tokens(&mut app, &staking_addr, &cw20_addr, ADDR3, 1); + } + + #[test] + fn test_cw20_rewards() { + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + let (staking_addr, cw20_addr) = setup_staking_contract(&mut app, initial_balances); + let reward_token = instantiate_cw20( + &mut app, + vec![Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(500000000), + }], + ); + let reward_addr = setup_reward_contract( + &mut app, + staking_addr.clone(), + Denom::Cw20(reward_token.clone()), + admin.clone(), + ); + + app.borrow_mut().update_block(|b| b.height = 1000); + + fund_rewards_cw20( + &mut app, + &admin, + reward_token.clone(), + &reward_addr, + 100000000, + ); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(1000)); + assert_eq!(res.reward.period_finish, 101000); + assert_eq!(res.reward.reward_duration, 100000); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 500); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 250); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 250); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 1000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 500); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 1500); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 750); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 750); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 2000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 1000); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 1000); + + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR1), + Uint128::zero() + ); + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR1), + Uint128::new(2000) + ); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 0); + + app.borrow_mut().update_block(|b| b.height += 10); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 5000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 3500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 3500); + + unstake_tokens(&mut app, &staking_addr, ADDR2, 50); + unstake_tokens(&mut app, &staking_addr, ADDR3, 50); + + app.borrow_mut().update_block(|b| b.height += 10); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 15000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 3500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 3500); + + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR1), + Uint128::new(17000) + ); + + claim_rewards(&mut app, reward_addr.clone(), ADDR2); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR2), + Uint128::new(3500) + ); + + stake_tokens(&mut app, &staking_addr, &cw20_addr, ADDR2, 50); + stake_tokens(&mut app, &staking_addr, &cw20_addr, ADDR3, 50); + + app.borrow_mut().update_block(|b| b.height += 10); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 5000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 2500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 6000); + + app.borrow_mut().update_block(|b| b.height = 101000); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 49988000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 24994000); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 24997500); + + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + claim_rewards(&mut app, reward_addr.clone(), ADDR2); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR1), + Uint128::new(50005000) + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR2), + Uint128::new(24997500) + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR3), + Uint128::new(0) + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, &reward_addr), + Uint128::new(24997500) + ); + + app.borrow_mut().update_block(|b| b.height = 200000); + + let reward_funding = vec![coin(200000000, denom)]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding, + } + })) + .unwrap(); + + fund_rewards_cw20( + &mut app, + &admin, + reward_token.clone(), + &reward_addr, + 200000000, + ); + + app.borrow_mut().update_block(|b| b.height = 300000); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 100000000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 50000000); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 74997500); + + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + claim_rewards(&mut app, reward_addr.clone(), ADDR2); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR1), + Uint128::new(150005000) + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR2), + Uint128::new(74997500) + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR3), + Uint128::zero() + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, &reward_addr), + Uint128::new(74997500) + ); + + // Add more rewards + fund_rewards_cw20( + &mut app, + &admin, + reward_token.clone(), + &reward_addr, + 200000000, + ); + + app.borrow_mut().update_block(|b| b.height = 400000); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 100000000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 50000000); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 124997500); + + claim_rewards(&mut app, reward_addr.clone(), ADDR1); + claim_rewards(&mut app, reward_addr.clone(), ADDR2); + claim_rewards(&mut app, reward_addr.clone(), ADDR3); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR1), + Uint128::new(250005000) + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR2), + Uint128::new(124997500) + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, ADDR3), + Uint128::new(124997500) + ); + assert_eq!( + get_balance_cw20(&app, &reward_token, &reward_addr), + Uint128::zero() + ); + + app.borrow_mut().update_block(|b| b.height = 500000); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 0); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 0); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 0); + + app.borrow_mut().update_block(|b| b.height = 1000000); + unstake_tokens(&mut app, &staking_addr, ADDR3, 1); + stake_tokens(&mut app, &staking_addr, &cw20_addr, ADDR3, 1); + } + + #[test] + fn update_rewards() { + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + let (staking_addr, _cw20_addr) = setup_staking_contract(&mut app, initial_balances); + let reward_funding = vec![coin(200000000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + // Add funding to Addr1 to make sure it can't update staking contract + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: ADDR1.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + let reward_addr = setup_reward_contract( + &mut app, + staking_addr, + Denom::Native(denom.clone()), + admin.clone(), + ); + + app.borrow_mut().update_block(|b| b.height = 1000); + + let fund_msg = ExecuteMsg::Fund {}; + + // None admin cannot update rewards + let err: ContractError = app + .borrow_mut() + .execute_contract( + Addr::unchecked(ADDR1), + reward_addr.clone(), + &fund_msg, + &reward_funding, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::Ownable(OwnershipError::NotOwner)); + + let _res = app + .borrow_mut() + .execute_contract( + admin.clone(), + reward_addr.clone(), + &fund_msg, + &reward_funding, + ) + .unwrap(); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(2000)); + assert_eq!(res.reward.period_finish, 101000); + assert_eq!(res.reward.reward_duration, 100000); + + // Create new period after old period + app.borrow_mut().update_block(|b| b.height = 101000); + + let reward_funding = vec![coin(100000000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + let _res = app + .borrow_mut() + .execute_contract( + admin.clone(), + reward_addr.clone(), + &fund_msg, + &reward_funding, + ) + .unwrap(); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(1000)); + assert_eq!(res.reward.period_finish, 201000); + assert_eq!(res.reward.reward_duration, 100000); + + // Add funds in middle of period returns an error + app.borrow_mut().update_block(|b| b.height = 151000); + + let reward_funding = vec![coin(200000000, denom)]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + let err = app + .borrow_mut() + .execute_contract(admin, reward_addr.clone(), &fund_msg, &reward_funding) + .unwrap_err(); + assert_eq!( + ContractError::RewardPeriodNotFinished {}, + err.downcast().unwrap() + ); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(1000)); + assert_eq!(res.reward.period_finish, 201000); + assert_eq!(res.reward.reward_duration, 100000); + } + + #[test] + fn update_reward_duration() { + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + let (staking_addr, _cw20_addr) = setup_staking_contract(&mut app, initial_balances); + + let reward_addr = setup_reward_contract( + &mut app, + staking_addr, + Denom::Native(denom.clone()), + admin.clone(), + ); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(0)); + assert_eq!(res.reward.period_finish, 0); + assert_eq!(res.reward.reward_duration, 100000); + + // Zero rewards durations are not allowed. + let msg = ExecuteMsg::UpdateRewardDuration { new_duration: 0 }; + let err: ContractError = app + .borrow_mut() + .execute_contract(admin.clone(), reward_addr.clone(), &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::ZeroRewardDuration {}); + + let msg = ExecuteMsg::UpdateRewardDuration { new_duration: 10 }; + let _resp = app + .borrow_mut() + .execute_contract(admin.clone(), reward_addr.clone(), &msg, &[]) + .unwrap(); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(0)); + assert_eq!(res.reward.period_finish, 0); + assert_eq!(res.reward.reward_duration, 10); + + // Non-admin cannot update rewards + let msg = ExecuteMsg::UpdateRewardDuration { new_duration: 100 }; + let err: ContractError = app + .borrow_mut() + .execute_contract(Addr::unchecked("non-admin"), reward_addr.clone(), &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Ownable(OwnershipError::NotOwner)); + + let reward_funding = vec![coin(1000, denom)]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + // Add funding to Addr1 to make sure it can't update staking contract + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: ADDR1.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + + app.borrow_mut().update_block(|b| b.height = 1000); + + let fund_msg = ExecuteMsg::Fund {}; + + let _res = app + .borrow_mut() + .execute_contract( + admin.clone(), + reward_addr.clone(), + &fund_msg, + &reward_funding, + ) + .unwrap(); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(100)); + assert_eq!(res.reward.period_finish, 1010); + assert_eq!(res.reward.reward_duration, 10); + + // Cannot update reward period before it finishes + let msg = ExecuteMsg::UpdateRewardDuration { new_duration: 10 }; + let err: ContractError = app + .borrow_mut() + .execute_contract(admin.clone(), reward_addr.clone(), &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::RewardPeriodNotFinished {}); + + // Update reward period once rewards are finished + app.borrow_mut().update_block(|b| b.height = 1010); + + let msg = ExecuteMsg::UpdateRewardDuration { new_duration: 100 }; + let _resp = app + .borrow_mut() + .execute_contract(admin, reward_addr.clone(), &msg, &[]) + .unwrap(); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(100)); + assert_eq!(res.reward.period_finish, 1010); + assert_eq!(res.reward.reward_duration, 100); + } + + #[test] + fn test_update_owner() { + let mut app = mock_app(); + let addr_owner = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + let (staking_addr, _cw20_addr) = setup_staking_contract(&mut app, initial_balances); + + let reward_addr = setup_reward_contract( + &mut app, + staking_addr, + Denom::Native(denom), + addr_owner.clone(), + ); + + let owner = get_ownership(&app, &reward_addr).owner; + assert_eq!(owner, Some(addr_owner.clone())); + + // random addr cannot update owner + let msg = ExecuteMsg::UpdateOwnership(Action::TransferOwnership { + new_owner: ADDR1.to_string(), + expiry: None, + }); + let err: ContractError = app + .borrow_mut() + .execute_contract(Addr::unchecked(ADDR1), reward_addr.clone(), &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Ownable(OwnershipError::NotOwner)); + + // owner nominates a new onwer. + app.borrow_mut() + .execute_contract(addr_owner.clone(), reward_addr.clone(), &msg, &[]) + .unwrap(); + + let ownership = get_ownership(&app, &reward_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(addr_owner), + pending_owner: Some(Addr::unchecked(ADDR1)), + pending_expiry: None, + } + ); + + // new owner accepts the nomination. + app.execute_contract( + Addr::unchecked(ADDR1), + reward_addr.clone(), + &ExecuteMsg::UpdateOwnership(Action::AcceptOwnership), + &[], + ) + .unwrap(); + + let ownership = get_ownership(&app, &reward_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(ADDR1)), + pending_owner: None, + pending_expiry: None, + } + ); + + // new owner renounces ownership. + app.execute_contract( + Addr::unchecked(ADDR1), + reward_addr.clone(), + &ExecuteMsg::UpdateOwnership(Action::RenounceOwnership), + &[], + ) + .unwrap(); + + let ownership = get_ownership(&app, &reward_addr); + assert_eq!( + ownership, + Ownership:: { + owner: None, + pending_owner: None, + pending_expiry: None, + } + ); + } + + #[test] + fn test_cannot_fund_with_wrong_coin_native() { + let mut app = mock_app(); + let owner = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + let (staking_addr, _cw20_addr) = setup_staking_contract(&mut app, initial_balances); + + let reward_addr = setup_reward_contract( + &mut app, + staking_addr, + Denom::Native(denom.clone()), + owner.clone(), + ); + + app.borrow_mut().update_block(|b| b.height = 1000); + + // No funding + let fund_msg = ExecuteMsg::Fund {}; + + let err: ContractError = app + .borrow_mut() + .execute_contract(owner.clone(), reward_addr.clone(), &fund_msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidFunds {}); + + // Invalid funding + let invalid_funding = vec![coin(100, "invalid")]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: owner.to_string(), + amount: invalid_funding.clone(), + } + })) + .unwrap(); + + let fund_msg = ExecuteMsg::Fund {}; + + let err: ContractError = app + .borrow_mut() + .execute_contract( + owner.clone(), + reward_addr.clone(), + &fund_msg, + &invalid_funding, + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidFunds {}); + + // Extra funding + let extra_funding = vec![coin(100, denom), coin(100, "extra")]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: owner.to_string(), + amount: extra_funding.clone(), + } + })) + .unwrap(); + + let fund_msg = ExecuteMsg::Fund {}; + + let err: ContractError = app + .borrow_mut() + .execute_contract( + owner.clone(), + reward_addr.clone(), + &fund_msg, + &extra_funding, + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidFunds {}); + + // Cw20 funding fails + let cw20_token = instantiate_cw20( + &mut app, + vec![Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(500000000), + }], + ); + let fund_sub_msg = to_binary(&ReceiveMsg::Fund {}).unwrap(); + let fund_msg = Cw20ExecuteMsg::Send { + contract: reward_addr.into_string(), + amount: Uint128::new(100), + msg: fund_sub_msg, + }; + let err: ContractError = app + .borrow_mut() + .execute_contract(owner, cw20_token, &fund_msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidCw20 {}); + } + + #[test] + fn test_cannot_fund_with_wrong_coin_cw20() { + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 _denom = "utest".to_string(); + let (staking_addr, _cw20_addr) = setup_staking_contract(&mut app, initial_balances); + let reward_token = instantiate_cw20( + &mut app, + vec![Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(500000000), + }], + ); + let reward_addr = setup_reward_contract( + &mut app, + staking_addr, + Denom::Cw20(Addr::unchecked("dummy_cw20")), + admin.clone(), + ); + + app.borrow_mut().update_block(|b| b.height = 1000); + + // Test with invalid token + let fund_sub_msg = to_binary(&ReceiveMsg::Fund {}).unwrap(); + let fund_msg = Cw20ExecuteMsg::Send { + contract: reward_addr.clone().into_string(), + amount: Uint128::new(100), + msg: fund_sub_msg, + }; + let err: ContractError = app + .borrow_mut() + .execute_contract(admin.clone(), reward_token, &fund_msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidCw20 {}); + + // Test does not work when funded with native + let invalid_funding = vec![coin(100, "invalid")]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: invalid_funding.clone(), + } + })) + .unwrap(); + + let fund_msg = ExecuteMsg::Fund {}; + + let err: ContractError = app + .borrow_mut() + .execute_contract(admin, reward_addr, &fund_msg, &invalid_funding) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidFunds {}) + } + + #[test] + fn test_rewards_with_zero_staked() { + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + // Instantiate cw20 contract + let cw20_addr = instantiate_cw20(&mut app, initial_balances.clone()); + app.update_block(next_block); + // Instantiate staking contract + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone(), None); + app.update_block(next_block); + let reward_funding = vec![coin(100000000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + let reward_addr = setup_reward_contract( + &mut app, + staking_addr.clone(), + Denom::Native(denom), + admin.clone(), + ); + + app.borrow_mut().update_block(|b| b.height = 1000); + + let fund_msg = ExecuteMsg::Fund {}; + + let _res = app + .borrow_mut() + .execute_contract(admin, reward_addr.clone(), &fund_msg, &reward_funding) + .unwrap(); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(1000)); + assert_eq!(res.reward.period_finish, 101000); + assert_eq!(res.reward.reward_duration, 100000); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 0); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 0); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 0); + + for coin in initial_balances { + stake_tokens( + &mut app, + &staking_addr, + &cw20_addr, + coin.address, + coin.amount.u128(), + ); + } + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 500); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 250); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 250); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 1000); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 500); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 500); + } + + #[test] + fn test_small_rewards() { + // This test was added due to a bug in the contract not properly paying out small reward + // amounts due to floor division + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + let (staking_addr, _) = setup_staking_contract(&mut app, initial_balances); + let reward_funding = vec![coin(1000000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + let reward_addr = + setup_reward_contract(&mut app, staking_addr, Denom::Native(denom), admin.clone()); + + app.borrow_mut().update_block(|b| b.height = 1000); + + let fund_msg = ExecuteMsg::Fund {}; + + let _res = app + .borrow_mut() + .execute_contract(admin, reward_addr.clone(), &fund_msg, &reward_funding) + .unwrap(); + + let res: InfoResponse = app + .borrow_mut() + .wrap() + .query_wasm_smart(&reward_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(res.reward.reward_rate, Uint128::new(10)); + assert_eq!(res.reward.period_finish, 101000); + assert_eq!(res.reward.reward_duration, 100000); + + app.borrow_mut().update_block(next_block); + assert_pending_rewards(&mut app, &reward_addr, ADDR1, 5); + assert_pending_rewards(&mut app, &reward_addr, ADDR2, 2); + assert_pending_rewards(&mut app, &reward_addr, ADDR3, 2); + } + + #[test] + fn test_zero_reward_rate_failed() { + // This test is due to a bug when funder provides rewards config that results in less then 1 + // reward per block which rounds down to zer0 + let mut app = mock_app(); + let admin = Addr::unchecked(OWNER); + app.borrow_mut().update_block(|b| b.height = 0); + 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 denom = "utest".to_string(); + let (staking_addr, _) = setup_staking_contract(&mut app, initial_balances); + let reward_funding = vec![coin(10000, denom.clone())]; + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: admin.to_string(), + amount: reward_funding.clone(), + } + })) + .unwrap(); + let reward_addr = + setup_reward_contract(&mut app, staking_addr, Denom::Native(denom), admin.clone()); + + app.borrow_mut().update_block(|b| b.height = 1000); + + let fund_msg = ExecuteMsg::Fund {}; + + let _res = app + .borrow_mut() + .execute_contract(admin, reward_addr, &fund_msg, &reward_funding) + .unwrap_err(); + } + + #[test] + fn test_migrate_from_v1() { + let mut app = App::default(); + + let v1_code = app.store_code(contract_rewards_v1()); + let v2_code = app.store_code(contract_rewards()); + + 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 denom = "utest".to_string(); + let (staking_addr, _) = setup_staking_contract(&mut app, initial_balances); + + let rewards_addr = app + .instantiate_contract( + v1_code, + Addr::unchecked(OWNER), + &v1::msg::InstantiateMsg { + owner: Some(OWNER.to_string()), + manager: Some(ADDR1.to_string()), + staking_contract: staking_addr.into_string(), + reward_token: cw20_013::Denom::Native(denom), + reward_duration: 10000, + }, + &[], + "rewards".to_string(), + Some(OWNER.to_string()), + ) + .unwrap(); + + app.execute( + Addr::unchecked(OWNER), + WasmMsg::Migrate { + contract_addr: rewards_addr.to_string(), + new_code_id: v2_code, + msg: to_binary(&MigrateMsg::FromV1 {}).unwrap(), + } + .into(), + ) + .unwrap(); + + let ownership = get_ownership(&app, &rewards_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(OWNER)), + pending_owner: None, + pending_expiry: None, + } + ); + + let err: ContractError = app + .execute( + Addr::unchecked(OWNER), + WasmMsg::Migrate { + contract_addr: rewards_addr.to_string(), + new_code_id: v2_code, + msg: to_binary(&MigrateMsg::FromV1 {}).unwrap(), + } + .into(), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::AlreadyMigrated {}); + } +} diff --git a/contracts/staking/cw20-stake-external-rewards/src/error.rs b/contracts/staking/cw20-stake-external-rewards/src/error.rs new file mode 100644 index 000000000..90725b4d5 --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/src/error.rs @@ -0,0 +1,28 @@ +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("Staking change hook sender is not staking contract")] + InvalidHookSender {}, + #[error("No rewards claimable")] + NoRewardsClaimable {}, + #[error("Reward period not finished")] + RewardPeriodNotFinished {}, + #[error("Invalid funds")] + InvalidFunds {}, + #[error("Invalid Cw20")] + InvalidCw20 {}, + #[error("Reward rate less then one per block")] + RewardRateLessThenOnePerBlock {}, + #[error("Reward duration can not be zero")] + ZeroRewardDuration {}, + #[error("can not migrate. current version is up to date")] + AlreadyMigrated {}, +} diff --git a/contracts/staking/cw20-stake-external-rewards/src/lib.rs b/contracts/staking/cw20-stake-external-rewards/src/lib.rs new file mode 100644 index 000000000..595daabe0 --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/src/lib.rs @@ -0,0 +1,8 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/staking/cw20-stake-external-rewards/src/msg.rs b/contracts/staking/cw20-stake-external-rewards/src/msg.rs new file mode 100644 index 000000000..565508e39 --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/src/msg.rs @@ -0,0 +1,70 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; +use cw20::{Cw20ReceiveMsg, Denom}; +use cw20_stake::hooks::StakeChangedHookMsg; + +use crate::state::{Config, RewardConfig}; + +pub use cw_controllers::ClaimsResponse; +// so that consumers don't need a cw_ownable dependency to consume +// this contract's queries. +pub use cw_ownable::Ownership; + +use cw_ownable::cw_ownable_execute; + +#[cw_serde] +pub struct InstantiateMsg { + pub owner: Option, + pub staking_contract: String, + pub reward_token: Denom, + pub reward_duration: u64, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + StakeChangeHook(StakeChangedHookMsg), + Claim {}, + Receive(Cw20ReceiveMsg), + Fund {}, + UpdateRewardDuration { new_duration: u64 }, +} + +#[cw_serde] +pub enum MigrateMsg { + /// Migrates from version 0.2.6 to 2.0.0. The significant changes + /// being the addition of a two-step ownership transfer using + /// `cw_ownable` and the removal of the manager. Migrating will + /// automatically remove the current manager. + FromV1 {}, +} + +#[cw_serde] +pub enum ReceiveMsg { + Fund {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(InfoResponse)] + Info {}, + #[returns(PendingRewardsResponse)] + GetPendingRewards { address: String }, + #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] + Ownership {}, +} + +#[cw_serde] +pub struct InfoResponse { + pub config: Config, + pub reward: RewardConfig, +} + +#[cw_serde] +pub struct PendingRewardsResponse { + pub address: String, + pub pending_rewards: Uint128, + pub denom: Denom, + pub last_update_block: u64, +} diff --git a/contracts/staking/cw20-stake-external-rewards/src/state.rs b/contracts/staking/cw20-stake-external-rewards/src/state.rs new file mode 100644 index 000000000..0c9e98d38 --- /dev/null +++ b/contracts/staking/cw20-stake-external-rewards/src/state.rs @@ -0,0 +1,30 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128, Uint256}; +use cw20::Denom; + +use cw_storage_plus::{Item, Map}; + +#[cw_serde] +pub struct Config { + pub staking_contract: Addr, + pub reward_token: Denom, +} + +// `"config"` key stores v1 configuration. +pub const CONFIG: Item = Item::new("config_v2"); + +#[cw_serde] +pub struct RewardConfig { + pub period_finish: u64, + pub reward_rate: Uint128, + pub reward_duration: u64, +} +pub const REWARD_CONFIG: Item = Item::new("reward_config"); + +pub const REWARD_PER_TOKEN: Item = Item::new("reward_per_token"); + +pub const LAST_UPDATE_BLOCK: Item = Item::new("last_update_block"); + +pub const PENDING_REWARDS: Map = Map::new("pending_rewards"); + +pub const USER_REWARD_PER_TOKEN: Map = Map::new("user_reward_per_token"); diff --git a/contracts/staking/cw20-stake-reward-distributor/.cargo/config b/contracts/staking/cw20-stake-reward-distributor/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/staking/cw20-stake-reward-distributor/Cargo.toml b/contracts/staking/cw20-stake-reward-distributor/Cargo.toml new file mode 100644 index 000000000..75972d965 --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "cw20-stake-reward-distributor" +edition = "2018" +authors = ["Vernon Johnson , ekez "] +description = "Distributes cw20 staking rewards." +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw-utils = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw20-stake = { workspace = true, features = ["library"]} +thiserror = { workspace = true } +cw-ownable = { workspace = true } +cw20-stake-reward-distributor-v1 = { workspace = true, features = ["library"] } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/contracts/staking/cw20-stake-reward-distributor/README.md b/contracts/staking/cw20-stake-reward-distributor/README.md new file mode 100644 index 000000000..46119b78e --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/README.md @@ -0,0 +1,5 @@ +# CW20 Stake Reward Distributor + +A contract to fund cw20-stake contracts with rewards in terms of the +same tokens being staked. + diff --git a/contracts/staking/cw20-stake-reward-distributor/examples/schema.rs b/contracts/staking/cw20-stake-reward-distributor/examples/schema.rs new file mode 100644 index 000000000..941213122 --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use cw20_stake_reward_distributor::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} 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 new file mode 100644 index 000000000..a816d65f7 --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/schema/cw20-stake-reward-distributor.json @@ -0,0 +1,443 @@ +{ + "contract_name": "cw20-stake-reward-distributor", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "owner", + "reward_rate", + "reward_token", + "staking_addr" + ], + "properties": { + "owner": { + "type": "string" + }, + "reward_rate": { + "$ref": "#/definitions/Uint128" + }, + "reward_token": { + "type": "string" + }, + "staking_addr": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "reward_rate", + "reward_token", + "staking_addr" + ], + "properties": { + "reward_rate": { + "$ref": "#/definitions/Uint128" + }, + "reward_token": { + "type": "string" + }, + "staking_addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribute" + ], + "properties": { + "distribute": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "withdraw" + ], + "properties": { + "withdraw": { + "type": "object", + "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" + ] + } + ] + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "description": "Updates the contract from v1 -> v2. Version two implements a two step ownership transfer.", + "type": "object", + "required": [ + "from_v1" + ], + "properties": { + "from_v1": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "sudo": null, + "responses": { + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "balance", + "config", + "last_payment_block" + ], + "properties": { + "balance": { + "$ref": "#/definitions/Uint128" + }, + "config": { + "$ref": "#/definitions/Config" + }, + "last_payment_block": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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" + }, + "Config": { + "type": "object", + "required": [ + "reward_rate", + "reward_token", + "staking_addr" + ], + "properties": { + "reward_rate": { + "$ref": "#/definitions/Uint128" + }, + "reward_token": { + "$ref": "#/definitions/Addr" + }, + "staking_addr": { + "$ref": "#/definitions/Addr" + } + }, + "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" + } + } + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_Addr", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "pending_expiry": { + "description": "The deadline for the pending owner to accept the ownership. `None` if there isn't a pending ownership transfer, or if a transfer exists and it doesn't have a deadline.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "pending_owner": { + "description": "The account who has been proposed to take over the ownership. `None` if there isn't a pending ownership transfer.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + } + } +} diff --git a/contracts/staking/cw20-stake-reward-distributor/src/contract.rs b/contracts/staking/cw20-stake-reward-distributor/src/contract.rs new file mode 100644 index 000000000..d4108d88c --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/src/contract.rs @@ -0,0 +1,276 @@ +use std::cmp::min; + +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{to_binary, Addr, CosmosMsg, StdError, Uint128, WasmMsg}; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InfoResponse, InstantiateMsg, MigrateMsg, QueryMsg}; +use crate::state::{Config, CONFIG, LAST_PAYMENT_BLOCK}; +use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw2::{get_contract_version, set_contract_version, ContractVersion}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:cw20-stake-reward-distributor"; +pub(crate) 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)?; + + let staking_addr = deps.api.addr_validate(&msg.staking_addr)?; + if !validate_staking(deps.as_ref(), staking_addr.clone()) { + return Err(ContractError::InvalidStakingContract {}); + } + + let reward_token = deps.api.addr_validate(&msg.reward_token)?; + if !validate_cw20(deps.as_ref(), reward_token.clone()) { + return Err(ContractError::InvalidCw20 {}); + } + + let config = Config { + staking_addr: staking_addr.clone(), + reward_token: reward_token.clone(), + reward_rate: msg.reward_rate, + }; + CONFIG.save(deps.storage, &config)?; + cw_ownable::initialize_owner(deps.storage, deps.api, Some(&msg.owner))?; + + // Initialize last payment block + LAST_PAYMENT_BLOCK.save(deps.storage, &env.block.height)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("owner", msg.owner) + .add_attribute("staking_addr", staking_addr.into_string()) + .add_attribute("reward_token", reward_token.into_string()) + .add_attribute("reward_rate", msg.reward_rate)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdateConfig { + staking_addr, + reward_rate, + reward_token, + } => execute_update_config(deps, info, env, staking_addr, reward_rate, reward_token), + ExecuteMsg::Distribute {} => execute_distribute(deps, env), + ExecuteMsg::Withdraw {} => execute_withdraw(deps, info, env), + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + } +} + +pub fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + env: Env, + staking_addr: String, + reward_rate: Uint128, + reward_token: String, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + LAST_PAYMENT_BLOCK.save(deps.storage, &env.block.height)?; + + let staking_addr = deps.api.addr_validate(&staking_addr)?; + if !validate_staking(deps.as_ref(), staking_addr.clone()) { + return Err(ContractError::InvalidStakingContract {}); + } + + let reward_token = deps.api.addr_validate(&reward_token)?; + if !validate_cw20(deps.as_ref(), reward_token.clone()) { + return Err(ContractError::InvalidCw20 {}); + } + + let config = Config { + staking_addr: staking_addr.clone(), + reward_token: reward_token.clone(), + reward_rate, + }; + CONFIG.save(deps.storage, &config)?; + + let resp = match get_distribution_msg(deps.as_ref(), &env) { + // distribution succeeded + Ok(msg) => Response::new().add_message(msg), + // distribution failed (either zero rewards or already distributed for block) + _ => Response::new(), + }; + + Ok(resp + .add_attribute("action", "update_config") + .add_attribute("staking_addr", staking_addr.into_string()) + .add_attribute("reward_token", reward_token.into_string()) + .add_attribute("reward_rate", reward_rate)) +} + +pub fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +pub fn validate_cw20(deps: Deps, cw20_addr: Addr) -> bool { + let response: Result = deps + .querier + .query_wasm_smart(cw20_addr, &cw20::Cw20QueryMsg::TokenInfo {}); + response.is_ok() +} + +pub fn validate_staking(deps: Deps, staking_addr: Addr) -> bool { + let response: Result = + deps.querier.query_wasm_smart( + staking_addr, + &cw20_stake::msg::QueryMsg::TotalStakedAtHeight { height: None }, + ); + response.is_ok() +} + +fn get_distribution_msg(deps: Deps, env: &Env) -> Result { + let config = CONFIG.load(deps.storage)?; + let last_payment_block = LAST_PAYMENT_BLOCK.load(deps.storage)?; + if last_payment_block >= env.block.height { + return Err(ContractError::RewardsDistributedForBlock {}); + } + let block_diff = env.block.height - last_payment_block; + + let pending_rewards: Uint128 = config.reward_rate * Uint128::new(block_diff.into()); + + let balance_info: cw20::BalanceResponse = deps.querier.query_wasm_smart( + config.reward_token.clone(), + &cw20::Cw20QueryMsg::Balance { + address: env.contract.address.to_string(), + }, + )?; + + let amount = min(balance_info.balance, pending_rewards); + + if amount == Uint128::zero() { + return Err(ContractError::ZeroRewards {}); + } + + let msg = to_binary(&cw20::Cw20ExecuteMsg::Send { + contract: config.staking_addr.clone().into_string(), + amount, + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Fund {}).unwrap(), + })?; + let send_msg: CosmosMsg = WasmMsg::Execute { + contract_addr: config.reward_token.into(), + msg, + funds: vec![], + } + .into(); + + Ok(send_msg) +} + +pub fn execute_distribute(deps: DepsMut, env: Env) -> Result { + let msg = get_distribution_msg(deps.as_ref(), &env)?; + LAST_PAYMENT_BLOCK.save(deps.storage, &env.block.height)?; + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "distribute")) +} + +pub fn execute_withdraw( + deps: DepsMut, + info: MessageInfo, + env: Env, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + let config = CONFIG.load(deps.storage)?; + + let balance_info: cw20::BalanceResponse = deps.querier.query_wasm_smart( + config.reward_token.clone(), + &cw20::Cw20QueryMsg::Balance { + address: env.contract.address.to_string(), + }, + )?; + + let msg = to_binary(&cw20::Cw20ExecuteMsg::Transfer { + // `assert_owner` call above validates that the sender is the + // owner. + recipient: info.sender.to_string(), + amount: balance_info.balance, + })?; + let send_msg: CosmosMsg = WasmMsg::Execute { + contract_addr: config.reward_token.into(), + msg, + funds: vec![], + } + .into(); + + Ok(Response::new() + .add_message(send_msg) + .add_attribute("action", "withdraw") + .add_attribute("amount", balance_info.balance) + .add_attribute("recipient", &info.sender)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Info {} => to_binary(&query_info(deps, env)?), + QueryMsg::Ownership {} => to_binary(&cw_ownable::get_ownership(deps.storage)?), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + use cw20_stake_reward_distributor_v1 as v1; + + let ContractVersion { version, .. } = get_contract_version(deps.storage)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + match msg { + MigrateMsg::FromV1 {} => { + if version == CONTRACT_VERSION { + // You can not possibly be migrating from v1 to v2 and + // also not changing your contract version. + return Err(ContractError::AlreadyMigrated {}); + } + // From v1 -> v2 we moved `owner` out of config and into + // the `cw_ownable` package. + let config = v1::state::CONFIG.load(deps.storage)?; + cw_ownable::initialize_owner(deps.storage, deps.api, Some(config.owner.as_str()))?; + let config = Config { + staking_addr: config.staking_addr, + reward_rate: config.reward_rate, + reward_token: config.reward_token, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default()) + } + } +} + +fn query_info(deps: Deps, env: Env) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let last_payment_block = LAST_PAYMENT_BLOCK.load(deps.storage)?; + let balance_info: cw20::BalanceResponse = deps.querier.query_wasm_smart( + config.reward_token.clone(), + &cw20::Cw20QueryMsg::Balance { + address: env.contract.address.to_string(), + }, + )?; + + Ok(InfoResponse { + config, + last_payment_block, + balance: balance_info.balance, + }) +} diff --git a/contracts/staking/cw20-stake-reward-distributor/src/error.rs b/contracts/staking/cw20-stake-reward-distributor/src/error.rs new file mode 100644 index 000000000..05a4a96ec --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/src/error.rs @@ -0,0 +1,26 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Ownership(#[from] cw_ownable::OwnershipError), + + #[error("Invalid Cw20")] + InvalidCw20 {}, + + #[error("Invalid Staking Contract")] + InvalidStakingContract {}, + + #[error("Zero eligible rewards")] + ZeroRewards {}, + + #[error("Rewards have already been distributed for this block")] + RewardsDistributedForBlock {}, + + #[error("can not migrate. current version is up to date")] + AlreadyMigrated {}, +} diff --git a/contracts/staking/cw20-stake-reward-distributor/src/lib.rs b/contracts/staking/cw20-stake-reward-distributor/src/lib.rs new file mode 100644 index 000000000..d1800adbc --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/staking/cw20-stake-reward-distributor/src/msg.rs b/contracts/staking/cw20-stake-reward-distributor/src/msg.rs new file mode 100644 index 000000000..47e347086 --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/src/msg.rs @@ -0,0 +1,53 @@ +use crate::state::Config; +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +use cw_ownable::cw_ownable_execute; + +// so that consumers don't need a cw_ownable dependency to consume +// this contract's queries. +pub use cw_ownable::Ownership; + +#[cw_serde] +pub struct InstantiateMsg { + pub owner: String, + pub staking_addr: String, + pub reward_rate: Uint128, + pub reward_token: String, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + UpdateConfig { + staking_addr: String, + reward_rate: Uint128, + reward_token: String, + }, + Distribute {}, + Withdraw {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(InfoResponse)] + Info {}, + + #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] + Ownership {}, +} + +#[cw_serde] +pub struct InfoResponse { + pub config: Config, + pub last_payment_block: u64, + pub balance: Uint128, +} + +#[cw_serde] +pub enum MigrateMsg { + /// Updates the contract from v1 -> v2. Version two implements a + /// two step ownership transfer. + FromV1 {}, +} diff --git a/contracts/staking/cw20-stake-reward-distributor/src/state.rs b/contracts/staking/cw20-stake-reward-distributor/src/state.rs new file mode 100644 index 000000000..0546c39a3 --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/src/state.rs @@ -0,0 +1,15 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::Item; + +#[cw_serde] +pub struct Config { + pub staking_addr: Addr, + pub reward_rate: Uint128, + pub reward_token: Addr, +} + +// `"config"` key stores v1 configuration. +pub const CONFIG: Item = Item::new("config_v2"); + +pub const LAST_PAYMENT_BLOCK: Item = Item::new("last_payment_block"); diff --git a/contracts/staking/cw20-stake-reward-distributor/src/tests.rs b/contracts/staking/cw20-stake-reward-distributor/src/tests.rs new file mode 100644 index 000000000..829fafaf4 --- /dev/null +++ b/contracts/staking/cw20-stake-reward-distributor/src/tests.rs @@ -0,0 +1,758 @@ +use crate::{ + msg::{ExecuteMsg, InfoResponse, InstantiateMsg, MigrateMsg, QueryMsg}, + state::Config, + ContractError, +}; + +use cw20_stake_reward_distributor_v1 as v1; + +use cosmwasm_std::{to_binary, Addr, Empty, Uint128, WasmMsg}; +use cw20::Cw20Coin; +use cw_multi_test::{next_block, App, Contract, ContractWrapper, Executor}; +use cw_ownable::{Action, Expiration, Ownership, OwnershipError}; + +const OWNER: &str = "owner"; +const OWNER2: &str = "owner2"; + +pub fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn staking_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_stake::contract::execute, + cw20_stake::contract::instantiate, + cw20_stake::contract::query, + ); + Box::new(contract) +} + +fn distributor_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +fn distributor_contract_v1() -> Box> { + let contract = ContractWrapper::new( + v1::contract::execute, + v1::contract::instantiate, + v1::contract::query, + ); + Box::new(contract) +} + +fn instantiate_cw20(app: &mut App, initial_balances: Vec) -> Addr { + let cw20_id = app.store_code(cw20_contract()); + let msg = cw20_base::msg::InstantiateMsg { + name: String::from("Test"), + symbol: String::from("TEST"), + decimals: 6, + initial_balances, + mint: None, + marketing: None, + }; + + app.instantiate_contract(cw20_id, Addr::unchecked(OWNER), &msg, &[], "cw20", None) + .unwrap() +} + +fn instantiate_staking(app: &mut App, cw20_addr: Addr) -> Addr { + let staking_id = app.store_code(staking_contract()); + let msg = cw20_stake::msg::InstantiateMsg { + owner: Some(OWNER.to_string()), + token_address: cw20_addr.to_string(), + unstaking_duration: None, + }; + app.instantiate_contract( + staking_id, + Addr::unchecked(OWNER), + &msg, + &[], + "staking", + None, + ) + .unwrap() +} + +fn instantiate_distributor(app: &mut App, msg: InstantiateMsg) -> Addr { + let code_id = app.store_code(distributor_contract()); + app.instantiate_contract( + code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "distributor", + None, + ) + .unwrap() +} + +fn get_balance_cw20, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn get_info>(app: &App, distributor_addr: T) -> InfoResponse { + let result: InfoResponse = app + .wrap() + .query_wasm_smart(distributor_addr, &QueryMsg::Info {}) + .unwrap(); + result +} + +fn get_owner(app: &App, contract: &Addr) -> Ownership { + app.wrap() + .query_wasm_smart(contract, &QueryMsg::Ownership {}) + .unwrap() +} + +#[test] +fn test_instantiate() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20(&mut app, vec![]); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(1), + reward_token: cw20_addr.to_string(), + }; + + let distributor_addr = instantiate_distributor(&mut app, msg); + let response: InfoResponse = app + .wrap() + .query_wasm_smart(&distributor_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!( + response.config, + Config { + staking_addr, + reward_rate: Uint128::new(1), + reward_token: cw20_addr, + } + ); + assert_eq!(response.last_payment_block, app.block_info().height); + + let ownership = get_owner(&app, &distributor_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(OWNER)), + pending_owner: None, + pending_expiry: None + } + ); +} + +#[test] +fn test_update_config() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20(&mut app, vec![]); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(1), + reward_token: cw20_addr.to_string(), + }; + let distributor_addr = instantiate_distributor(&mut app, msg); + + let msg = ExecuteMsg::UpdateConfig { + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(5), + reward_token: cw20_addr.to_string(), + }; + + app.execute_contract(Addr::unchecked(OWNER), distributor_addr.clone(), &msg, &[]) + .unwrap(); + + let response: InfoResponse = app + .wrap() + .query_wasm_smart(&distributor_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!( + response.config, + Config { + staking_addr: staking_addr.clone(), + reward_rate: Uint128::new(5), + reward_token: cw20_addr.clone(), + } + ); + + let msg = ExecuteMsg::UpdateConfig { + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(7), + reward_token: cw20_addr.to_string(), + }; + + // non-owner may not update config. + let err: ContractError = app + .execute_contract(Addr::unchecked("notowner"), distributor_addr, &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::Ownership(OwnershipError::NotOwner)); +} + +#[test] +fn test_distribute() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::from(1000u64), + }], + ); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(1), + reward_token: cw20_addr.to_string(), + }; + let distributor_addr = instantiate_distributor(&mut app, msg); + + let msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: distributor_addr.to_string(), + amount: Uint128::from(1000u128), + }; + app.execute_contract(Addr::unchecked(OWNER), cw20_addr.clone(), &msg, &[]) + .unwrap(); + + app.update_block(|block| block.height += 10); + app.execute_contract( + Addr::unchecked(OWNER), + distributor_addr.clone(), + &ExecuteMsg::Distribute {}, + &[], + ) + .unwrap(); + + let staking_balance = get_balance_cw20(&app, cw20_addr.clone(), staking_addr.clone()); + assert_eq!(staking_balance, Uint128::new(10)); + + let distributor_info = get_info(&app, distributor_addr.clone()); + assert_eq!(distributor_info.balance, Uint128::new(990)); + assert_eq!(distributor_info.last_payment_block, app.block_info().height); + + app.update_block(|block| block.height += 500); + app.execute_contract( + Addr::unchecked(OWNER), + distributor_addr.clone(), + &ExecuteMsg::Distribute {}, + &[], + ) + .unwrap(); + + let staking_balance = get_balance_cw20(&app, cw20_addr.clone(), staking_addr.clone()); + assert_eq!(staking_balance, Uint128::new(510)); + + let distributor_info = get_info(&app, distributor_addr.clone()); + assert_eq!(distributor_info.balance, Uint128::new(490)); + assert_eq!(distributor_info.last_payment_block, app.block_info().height); + + app.update_block(|block| block.height += 1000); + app.execute_contract( + Addr::unchecked(OWNER), + distributor_addr.clone(), + &ExecuteMsg::Distribute {}, + &[], + ) + .unwrap(); + + let staking_balance = get_balance_cw20(&app, cw20_addr.clone(), staking_addr.clone()); + assert_eq!(staking_balance, Uint128::new(1000)); + + let distributor_info = get_info(&app, distributor_addr.clone()); + assert_eq!(distributor_info.balance, Uint128::new(0)); + assert_eq!(distributor_info.last_payment_block, app.block_info().height); + let last_payment_block = distributor_info.last_payment_block; + + // Pays out nothing + app.update_block(|block| block.height += 1100); + let err: ContractError = app + .execute_contract( + Addr::unchecked(OWNER), + distributor_addr.clone(), + &ExecuteMsg::Distribute {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert!(matches!(err, ContractError::ZeroRewards {})); + + let staking_balance = get_balance_cw20(&app, cw20_addr, staking_addr); + assert_eq!(staking_balance, Uint128::new(1000)); + + let distributor_info = get_info(&app, distributor_addr.clone()); + assert_eq!(distributor_info.balance, Uint128::new(0)); + assert_eq!(distributor_info.last_payment_block, last_payment_block); + + // go to a block before the last payment + app.update_block(|block| block.height -= 2000); + let err: ContractError = app + .execute_contract( + Addr::unchecked(OWNER), + distributor_addr, + &ExecuteMsg::Distribute {}, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, ContractError::RewardsDistributedForBlock {})); +} + +#[test] +fn test_instantiate_invalid_addrs() { + let mut app = App::default(); + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::from(1000u64), + }], + ); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(1), + reward_token: "invalid_cw20".to_string(), + }; + + let code_id = app.store_code(distributor_contract()); + let err: ContractError = app + .instantiate_contract( + code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "distributor", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + + assert_eq!(err, ContractError::InvalidCw20 {}); + + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: "invalid_staking".to_string(), + reward_rate: Uint128::new(1), + reward_token: cw20_addr.to_string(), + }; + let err: ContractError = app + .instantiate_contract( + code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "distributor", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidStakingContract {}); +} + +#[test] +fn test_update_config_invalid_addrs() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20(&mut app, vec![]); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(1), + reward_token: cw20_addr.to_string(), + }; + let distributor_addr = instantiate_distributor(&mut app, msg); + + let msg = ExecuteMsg::UpdateConfig { + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(5), + reward_token: "invalid_cw20".to_string(), + }; + + let err: ContractError = app + .execute_contract(Addr::unchecked(OWNER), distributor_addr.clone(), &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidCw20 {}); + + let msg = ExecuteMsg::UpdateConfig { + staking_addr: "invalid_staking".to_string(), + reward_rate: Uint128::new(5), + reward_token: staking_addr.to_string(), + }; + + let err: ContractError = app + .execute_contract(Addr::unchecked(OWNER), distributor_addr, &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidStakingContract {}); +} + +#[test] +fn test_withdraw() { + let mut app = App::default(); + + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::from(1000u64), + }], + ); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(1), + reward_token: cw20_addr.to_string(), + }; + let distributor_addr = instantiate_distributor(&mut app, msg); + + let msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: distributor_addr.to_string(), + amount: Uint128::from(1000u128), + }; + app.execute_contract(Addr::unchecked(OWNER), cw20_addr.clone(), &msg, &[]) + .unwrap(); + + app.update_block(|block| block.height += 10); + app.execute_contract( + Addr::unchecked(OWNER), + distributor_addr.clone(), + &ExecuteMsg::Distribute {}, + &[], + ) + .unwrap(); + + let staking_balance = get_balance_cw20(&app, cw20_addr.clone(), staking_addr); + assert_eq!(staking_balance, Uint128::new(10)); + + let distributor_info = get_info(&app, distributor_addr.clone()); + assert_eq!(distributor_info.balance, Uint128::new(990)); + assert_eq!(distributor_info.last_payment_block, app.block_info().height); + + // Unauthorized user cannot withdraw funds + let err = app + .execute_contract( + Addr::unchecked("notowner"), + distributor_addr.clone(), + &ExecuteMsg::Withdraw {}, + &[], + ) + .unwrap_err(); + + assert_eq!( + ContractError::Ownership(OwnershipError::NotOwner), + err.downcast().unwrap() + ); + + // Withdraw funds + app.execute_contract( + Addr::unchecked(OWNER), + distributor_addr, + &ExecuteMsg::Withdraw {}, + &[], + ) + .unwrap(); + + let owner_balance = get_balance_cw20(&app, cw20_addr, Addr::unchecked(OWNER)); + assert_eq!(owner_balance, Uint128::new(990)); +} + +#[test] +fn test_dao_deploy() { + // DAOs will deploy this contract with following steps + // Contract is instantiated by any address with 0 reward rate + // Dao updates reward rate and funds in same transaction + let mut app = App::default(); + + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::from(1000u64), + }], + ); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(0), + reward_token: cw20_addr.to_string(), + }; + let distributor_addr = instantiate_distributor(&mut app, msg); + + let msg = ExecuteMsg::UpdateConfig { + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(1), + reward_token: cw20_addr.to_string(), + }; + app.execute_contract(Addr::unchecked(OWNER), distributor_addr.clone(), &msg, &[]) + .unwrap(); + + let msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: distributor_addr.to_string(), + amount: Uint128::from(1000u128), + }; + app.execute_contract(Addr::unchecked(OWNER), cw20_addr.clone(), &msg, &[]) + .unwrap(); + + app.update_block(|block| block.height += 10); + app.execute_contract( + Addr::unchecked(OWNER), + distributor_addr.clone(), + &ExecuteMsg::Distribute {}, + &[], + ) + .unwrap(); + + let staking_balance = get_balance_cw20(&app, cw20_addr, staking_addr); + assert_eq!(staking_balance, Uint128::new(10)); + + let distributor_info = get_info(&app, distributor_addr); + assert_eq!(distributor_info.balance, Uint128::new(990)); + assert_eq!(distributor_info.last_payment_block, app.block_info().height); +} + +#[test] +fn test_ownership() { + let mut app = App::default(); + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::from(1000u64), + }], + ); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(0), + reward_token: cw20_addr.to_string(), + }; + let distributor_addr = instantiate_distributor(&mut app, msg); + + app.execute_contract( + Addr::unchecked(OWNER), + distributor_addr.clone(), + &ExecuteMsg::UpdateOwnership(Action::TransferOwnership { + new_owner: OWNER2.to_string(), + expiry: None, + }), + &[], + ) + .unwrap(); + + let ownership = get_owner(&app, &distributor_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(OWNER)), + pending_owner: Some(Addr::unchecked(OWNER2)), + pending_expiry: None + } + ); + + app.execute_contract( + Addr::unchecked(OWNER2), + distributor_addr.clone(), + &ExecuteMsg::UpdateOwnership(Action::AcceptOwnership), + &[], + ) + .unwrap(); + + let ownership = get_owner(&app, &distributor_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(OWNER2)), + pending_owner: None, + pending_expiry: None + } + ); +} + +#[test] +fn test_ownership_expiry() { + let mut app = App::default(); + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::from(1000u64), + }], + ); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + let msg = InstantiateMsg { + owner: OWNER.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(0), + reward_token: cw20_addr.to_string(), + }; + let distributor_addr = instantiate_distributor(&mut app, msg); + + app.execute_contract( + Addr::unchecked(OWNER), + distributor_addr.clone(), + &ExecuteMsg::UpdateOwnership(Action::TransferOwnership { + new_owner: OWNER2.to_string(), + expiry: Some(Expiration::AtHeight(app.block_info().height + 1)), + }), + &[], + ) + .unwrap(); + + let ownership = get_owner(&app, &distributor_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(OWNER)), + pending_owner: Some(Addr::unchecked(OWNER2)), + pending_expiry: Some(Expiration::AtHeight(app.block_info().height + 1)), + } + ); + + app.update_block(next_block); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(OWNER2), + distributor_addr, + &ExecuteMsg::UpdateOwnership(Action::AcceptOwnership), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::Ownership(OwnershipError::TransferExpired) + ) +} + +#[test] +fn test_migrate_from_v1() { + let mut app = App::default(); + let sender = Addr::unchecked("sender"); + + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: sender.to_string(), + amount: Uint128::from(1000u64), + }], + ); + let staking_addr = instantiate_staking(&mut app, cw20_addr.clone()); + + let v1_code = app.store_code(distributor_contract_v1()); + let v2_code = app.store_code(distributor_contract()); + let distributor = app + .instantiate_contract( + v1_code, + sender.clone(), + &v1::msg::InstantiateMsg { + owner: sender.to_string(), + staking_addr: staking_addr.to_string(), + reward_rate: Uint128::new(1), + reward_token: cw20_addr.to_string(), + }, + &[], + "distributor", + Some(sender.to_string()), + ) + .unwrap(); + app.execute( + sender.clone(), + WasmMsg::Migrate { + contract_addr: distributor.to_string(), + new_code_id: v2_code, + msg: to_binary(&MigrateMsg::FromV1 {}).unwrap(), + } + .into(), + ) + .unwrap(); + + let ownership = get_owner(&app, &distributor); + assert_eq!( + ownership, + Ownership:: { + owner: Some(sender.clone()), + pending_owner: None, + pending_expiry: None, + } + ); + + let info = get_info(&app, &distributor); + assert_eq!( + info, + InfoResponse { + config: Config { + staking_addr, + reward_rate: Uint128::new(1), + reward_token: cw20_addr + }, + last_payment_block: app.block_info().height, + balance: Uint128::zero() + } + ); + + let err: ContractError = app + .execute( + sender, + WasmMsg::Migrate { + contract_addr: distributor.to_string(), + new_code_id: v2_code, + msg: to_binary(&MigrateMsg::FromV1 {}).unwrap(), + } + .into(), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::AlreadyMigrated {}); +} diff --git a/contracts/staking/cw20-stake/.cargo/config b/contracts/staking/cw20-stake/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/staking/cw20-stake/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/staking/cw20-stake/Cargo.toml b/contracts/staking/cw20-stake/Cargo.toml new file mode 100644 index 000000000..a3706eed7 --- /dev/null +++ b/contracts/staking/cw20-stake/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "cw20-stake" +authors = ["Ben2x4 "] +description = "CW20 token that can be staked and staked balance can be queried at any height" +edition = "2018" +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-storage = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +cw20 = { workspace = true } +cw-utils = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw2 = { workspace = true } +thiserror = { workspace = true } +cw-paginate-storage = { workspace = true } +cw-ownable = { workspace = true } + +cw20-stake-v1 = { workspace = true, features = ["library"] } +cw-utils-v1 = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = { workspace = true } diff --git a/contracts/staking/cw20-stake/README.md b/contracts/staking/cw20-stake/README.md new file mode 100644 index 000000000..a359ac18a --- /dev/null +++ b/contracts/staking/cw20-stake/README.md @@ -0,0 +1,5 @@ +# CW20 Stake + +This is a basic implementation of a cw20 staking contract. Staked +tokens can be unbonded with a configurable unbonding period. Staked +balances can be queried at any arbitrary height by external contracts. diff --git a/contracts/staking/cw20-stake/examples/schema.rs b/contracts/staking/cw20-stake/examples/schema.rs new file mode 100644 index 000000000..8ab415a98 --- /dev/null +++ b/contracts/staking/cw20-stake/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use cw20_stake::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/staking/cw20-stake/schema/cw20-stake.json b/contracts/staking/cw20-stake/schema/cw20-stake.json new file mode 100644 index 000000000..285f8387a --- /dev/null +++ b/contracts/staking/cw20-stake/schema/cw20-stake.json @@ -0,0 +1,999 @@ +{ + "contract_name": "cw20-stake", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "token_address" + ], + "properties": { + "owner": { + "type": [ + "string", + "null" + ] + }, + "token_address": { + "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 + } + ] + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "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" + ] + } + ] + }, + "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 + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "staked_balance_at_height" + ], + "properties": { + "staked_balance_at_height": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + }, + "height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "total_staked_at_height" + ], + "properties": { + "total_staked_at_height": { + "type": "object", + "properties": { + "height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staked_value" + ], + "properties": { + "staked_value": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "total_value" + ], + "properties": { + "total_value": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_config" + ], + "properties": { + "get_config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "claims" + ], + "properties": { + "claims": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_hooks" + ], + "properties": { + "get_hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "list_stakers" + ], + "properties": { + "list_stakers": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "oneOf": [ + { + "description": "Migrates the contract from version one to version two. This will remove the contract's current manager, and require a nomination -> acceptance flow for future ownership transfers.", + "type": "object", + "required": [ + "from_v1" + ], + "properties": { + "from_v1": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "sudo": null, + "responses": { + "claims": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ClaimsResponse", + "type": "object", + "required": [ + "claims" + ], + "properties": { + "claims": { + "type": "array", + "items": { + "$ref": "#/definitions/Claim" + } + } + }, + "additionalProperties": false, + "definitions": { + "Claim": { + "type": "object", + "required": [ + "amount", + "release_at" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "release_at": { + "$ref": "#/definitions/Expiration" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "get_config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "token_address" + ], + "properties": { + "token_address": { + "$ref": "#/definitions/Addr" + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "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" + }, + "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 + } + ] + } + } + }, + "get_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetHooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "list_stakers": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ListStakersResponse", + "type": "object", + "required": [ + "stakers" + ], + "properties": { + "stakers": { + "type": "array", + "items": { + "$ref": "#/definitions/StakerBalanceResponse" + } + } + }, + "additionalProperties": false, + "definitions": { + "StakerBalanceResponse": { + "type": "object", + "required": [ + "address", + "balance" + ], + "properties": { + "address": { + "type": "string" + }, + "balance": { + "$ref": "#/definitions/Uint128" + } + }, + "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" + } + } + }, + "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" + } + } + }, + "staked_balance_at_height": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StakedBalanceAtHeightResponse", + "type": "object", + "required": [ + "balance", + "height" + ], + "properties": { + "balance": { + "$ref": "#/definitions/Uint128" + }, + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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" + } + } + }, + "staked_value": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "StakedValueResponse", + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "$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" + } + } + }, + "total_staked_at_height": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TotalStakedAtHeightResponse", + "type": "object", + "required": [ + "height", + "total" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "total": { + "$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" + } + } + }, + "total_value": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TotalValueResponse", + "type": "object", + "required": [ + "total" + ], + "properties": { + "total": { + "$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/staking/cw20-stake/src/contract.rs b/contracts/staking/cw20-stake/src/contract.rs new file mode 100644 index 000000000..d25417f87 --- /dev/null +++ b/contracts/staking/cw20-stake/src/contract.rs @@ -0,0 +1,505 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; + +use cosmwasm_std::{ + from_binary, to_binary, Addr, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, + StdError, StdResult, Uint128, +}; + +use cw20::{Cw20ReceiveMsg, TokenInfoResponse}; + +use crate::hooks::{stake_hook_msgs, unstake_hook_msgs}; +use crate::math; +use crate::msg::{ + ExecuteMsg, GetHooksResponse, InstantiateMsg, ListStakersResponse, MigrateMsg, QueryMsg, + ReceiveMsg, StakedBalanceAtHeightResponse, StakedValueResponse, StakerBalanceResponse, + TotalStakedAtHeightResponse, TotalValueResponse, +}; +use crate::state::{ + Config, BALANCE, CLAIMS, CONFIG, HOOKS, MAX_CLAIMS, STAKED_BALANCES, STAKED_TOTAL, +}; +use crate::ContractError; +use cw2::{get_contract_version, set_contract_version, ContractVersion}; +pub use cw20_base::allowances::{ + execute_burn_from, execute_decrease_allowance, execute_increase_allowance, execute_send_from, + execute_transfer_from, query_allowance, +}; +pub use cw20_base::contract::{ + execute_burn, execute_mint, execute_send, execute_transfer, execute_update_marketing, + execute_upload_logo, query_balance, query_download_logo, query_marketing_info, query_minter, + query_token_info, +}; +pub use cw20_base::enumerable::{query_all_accounts, query_owner_allowances}; +use cw_controllers::ClaimsResponse; +use cw_utils::Duration; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:cw20-stake"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn validate_duration(duration: Option) -> Result<(), ContractError> { + if let Some(unstaking_duration) = duration { + match unstaking_duration { + Duration::Height(height) => { + if height == 0 { + return Err(ContractError::InvalidUnstakingDuration {}); + } + } + Duration::Time(time) => { + if time == 0 { + return Err(ContractError::InvalidUnstakingDuration {}); + } + } + } + } + Ok(()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result, ContractError> { + cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + // Smoke test that the provided cw20 contract responds to a + // token_info query. It is not possible to determine if the + // contract implements the entire cw20 standard and runtime, + // though this provides some protection against mistakes where the + // wrong address is provided. + let token_address = deps.api.addr_validate(&msg.token_address)?; + let _: TokenInfoResponse = deps + .querier + .query_wasm_smart(&token_address, &cw20::Cw20QueryMsg::TokenInfo {}) + .map_err(|_| ContractError::InvalidCw20 {})?; + + validate_duration(msg.unstaking_duration)?; + let config = Config { + token_address, + unstaking_duration: msg.unstaking_duration, + }; + CONFIG.save(deps.storage, &config)?; + + // Initialize state to zero. We do this instead of using + // `unwrap_or_default` where this is used as it protects us + // against a scenerio where state is cleared by a bad actor and + // `unwrap_or_default` carries on. + STAKED_TOTAL.save(deps.storage, &Uint128::zero(), env.block.height)?; + BALANCE.save(deps.storage, &Uint128::zero())?; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), + ExecuteMsg::Unstake { amount } => execute_unstake(deps, env, info, amount), + ExecuteMsg::Claim {} => execute_claim(deps, env, info), + ExecuteMsg::UpdateConfig { duration } => execute_update_config(info, deps, duration), + ExecuteMsg::AddHook { addr } => execute_add_hook(deps, env, info, addr), + ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, env, info, addr), + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + } +} + +pub fn execute_update_config( + info: MessageInfo, + deps: DepsMut, + duration: Option, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + validate_duration(duration)?; + + CONFIG.update(deps.storage, |mut config| -> Result { + config.unstaking_duration = duration; + Ok(config) + })?; + + Ok(Response::new() + .add_attribute("action", "update_config") + .add_attribute( + "unstaking_duration", + duration + .map(|d| format!("{d}")) + .unwrap_or_else(|| "none".to_string()), + )) +} + +pub fn execute_receive( + deps: DepsMut, + env: Env, + info: MessageInfo, + wrapper: Cw20ReceiveMsg, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if info.sender != config.token_address { + return Err(ContractError::InvalidToken { + received: info.sender, + expected: config.token_address, + }); + } + let msg: ReceiveMsg = from_binary(&wrapper.msg)?; + let sender = deps.api.addr_validate(&wrapper.sender)?; + match msg { + ReceiveMsg::Stake {} => execute_stake(deps, env, sender, wrapper.amount), + ReceiveMsg::Fund {} => execute_fund(deps, env, &sender, wrapper.amount), + } +} + +pub fn execute_stake( + deps: DepsMut, + env: Env, + sender: Addr, + amount: Uint128, +) -> Result { + let balance = BALANCE.load(deps.storage)?; + let staked_total = STAKED_TOTAL.load(deps.storage)?; + let amount_to_stake = math::amount_to_stake(staked_total, balance, amount); + STAKED_BALANCES.update( + deps.storage, + &sender, + env.block.height, + |bal| -> StdResult { Ok(bal.unwrap_or_default().checked_add(amount_to_stake)?) }, + )?; + STAKED_TOTAL.update( + deps.storage, + env.block.height, + |total| -> StdResult { + // Initialized during instantiate - OK to unwrap. + Ok(total.unwrap().checked_add(amount_to_stake)?) + }, + )?; + BALANCE.save( + deps.storage, + &balance.checked_add(amount).map_err(StdError::overflow)?, + )?; + let hook_msgs = stake_hook_msgs(deps.storage, sender.clone(), amount_to_stake)?; + Ok(Response::new() + .add_submessages(hook_msgs) + .add_attribute("action", "stake") + .add_attribute("from", sender) + .add_attribute("amount", amount)) +} + +pub fn execute_unstake( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let balance = BALANCE.load(deps.storage)?; + let staked_total = STAKED_TOTAL.load(deps.storage)?; + // invariant checks for amount_to_claim + if staked_total.is_zero() { + return Err(ContractError::NothingStaked {}); + } + if amount.saturating_add(balance) == Uint128::MAX { + return Err(ContractError::Cw20InvaraintViolation {}); + } + if amount > staked_total { + return Err(ContractError::ImpossibleUnstake {}); + } + let amount_to_claim = math::amount_to_claim(staked_total, balance, amount); + STAKED_BALANCES.update( + deps.storage, + &info.sender, + env.block.height, + |bal| -> StdResult { Ok(bal.unwrap_or_default().checked_sub(amount)?) }, + )?; + STAKED_TOTAL.update( + deps.storage, + env.block.height, + |total| -> StdResult { + // Initialized during instantiate - OK to unwrap. + Ok(total.unwrap().checked_sub(amount)?) + }, + )?; + BALANCE.save( + deps.storage, + &balance + .checked_sub(amount_to_claim) + .map_err(StdError::overflow)?, + )?; + let hook_msgs = unstake_hook_msgs(deps.storage, info.sender.clone(), amount)?; + match config.unstaking_duration { + None => { + let cw_send_msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: info.sender.to_string(), + amount: amount_to_claim, + }; + let wasm_msg = cosmwasm_std::WasmMsg::Execute { + contract_addr: config.token_address.to_string(), + msg: to_binary(&cw_send_msg)?, + funds: vec![], + }; + Ok(Response::new() + .add_message(wasm_msg) + .add_submessages(hook_msgs) + .add_attribute("action", "unstake") + .add_attribute("from", info.sender) + .add_attribute("amount", amount) + .add_attribute("claim_duration", "None")) + } + Some(duration) => { + let outstanding_claims = CLAIMS.query_claims(deps.as_ref(), &info.sender)?.claims; + if outstanding_claims.len() + 1 > MAX_CLAIMS as usize { + return Err(ContractError::TooManyClaims {}); + } + + CLAIMS.create_claim( + deps.storage, + &info.sender, + amount_to_claim, + duration.after(&env.block), + )?; + Ok(Response::new() + .add_attribute("action", "unstake") + .add_submessages(hook_msgs) + .add_attribute("from", info.sender) + .add_attribute("amount", amount) + .add_attribute("claim_duration", format!("{duration}"))) + } + } +} + +pub fn execute_claim( + deps: DepsMut, + _env: Env, + info: MessageInfo, +) -> Result { + let release = CLAIMS.claim_tokens(deps.storage, &info.sender, &_env.block, None)?; + if release.is_zero() { + return Err(ContractError::NothingToClaim {}); + } + let config = CONFIG.load(deps.storage)?; + let cw_send_msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: info.sender.to_string(), + amount: release, + }; + let wasm_msg = cosmwasm_std::WasmMsg::Execute { + contract_addr: config.token_address.to_string(), + msg: to_binary(&cw_send_msg)?, + funds: vec![], + }; + Ok(Response::new() + .add_message(wasm_msg) + .add_attribute("action", "claim") + .add_attribute("from", info.sender) + .add_attribute("amount", release)) +} + +pub fn execute_fund( + deps: DepsMut, + _env: Env, + sender: &Addr, + amount: Uint128, +) -> Result { + BALANCE.update(deps.storage, |balance| -> StdResult<_> { + balance.checked_add(amount).map_err(StdError::overflow) + })?; + Ok(Response::new() + .add_attribute("action", "fund") + .add_attribute("from", sender) + .add_attribute("amount", amount)) +} + +pub fn execute_add_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + addr: String, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + let hook = deps.api.addr_validate(&addr)?; + HOOKS.add_hook(deps.storage, hook)?; + Ok(Response::new() + .add_attribute("action", "add_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_remove_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + addr: String, +) -> Result { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + let hook = deps.api.addr_validate(&addr)?; + HOOKS.remove_hook(deps.storage, hook)?; + Ok(Response::new() + .add_attribute("action", "remove_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetConfig {} => to_binary(&query_config(deps)?), + QueryMsg::StakedBalanceAtHeight { address, height } => { + to_binary(&query_staked_balance_at_height(deps, env, address, height)?) + } + QueryMsg::TotalStakedAtHeight { height } => { + to_binary(&query_total_staked_at_height(deps, env, height)?) + } + QueryMsg::StakedValue { address } => to_binary(&query_staked_value(deps, env, address)?), + QueryMsg::TotalValue {} => to_binary(&query_total_value(deps, env)?), + QueryMsg::Claims { address } => to_binary(&query_claims(deps, address)?), + QueryMsg::GetHooks {} => to_binary(&query_hooks(deps)?), + QueryMsg::ListStakers { start_after, limit } => { + query_list_stakers(deps, start_after, limit) + } + QueryMsg::Ownership {} => to_binary(&cw_ownable::get_ownership(deps.storage)?), + } +} + +pub fn query_staked_balance_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 balance = STAKED_BALANCES + .may_load_at_height(deps.storage, &address, height)? + .unwrap_or_default(); + Ok(StakedBalanceAtHeightResponse { balance, height }) +} + +pub fn query_total_staked_at_height( + deps: Deps, + _env: Env, + height: Option, +) -> StdResult { + let height = height.unwrap_or(_env.block.height); + let total = STAKED_TOTAL + .may_load_at_height(deps.storage, height)? + .unwrap_or_default(); + Ok(TotalStakedAtHeightResponse { total, height }) +} + +pub fn query_staked_value( + deps: Deps, + _env: Env, + address: String, +) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let balance = BALANCE.load(deps.storage).unwrap_or_default(); + let staked = STAKED_BALANCES + .load(deps.storage, &address) + .unwrap_or_default(); + let total = STAKED_TOTAL.load(deps.storage)?; + if balance == Uint128::zero() || staked == Uint128::zero() || total == Uint128::zero() { + Ok(StakedValueResponse { + value: Uint128::zero(), + }) + } else { + let value = staked + .checked_mul(balance) + .map_err(StdError::overflow)? + .checked_div(total) + .map_err(StdError::divide_by_zero)?; + Ok(StakedValueResponse { value }) + } +} + +pub fn query_total_value(deps: Deps, _env: Env) -> StdResult { + let balance = BALANCE.load(deps.storage)?; + Ok(TotalValueResponse { total: balance }) +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(config) +} + +pub fn query_claims(deps: Deps, address: String) -> StdResult { + CLAIMS.query_claims(deps, &deps.api.addr_validate(&address)?) +} + +pub fn query_hooks(deps: Deps) -> StdResult { + Ok(GetHooksResponse { + hooks: HOOKS.query_hooks(deps)?.hooks, + }) +} + +pub fn query_list_stakers( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let start_at = start_after + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + + let stakers = cw_paginate_storage::paginate_snapshot_map( + deps, + &STAKED_BALANCES, + start_at.as_ref(), + limit, + cosmwasm_std::Order::Ascending, + )?; + + let stakers = stakers + .into_iter() + .map(|(address, balance)| StakerBalanceResponse { + address: address.into_string(), + balance, + }) + .collect(); + + to_binary(&ListStakersResponse { stakers }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + use cw20_stake_v1 as v1; + + let ContractVersion { version, .. } = get_contract_version(deps.storage)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + match msg { + MigrateMsg::FromV1 {} => { + if version == CONTRACT_VERSION { + // Migrating from a version to a new one implies that + // the new version must be different. + return Err(ContractError::AlreadyMigrated {}); + } + let config = v1::state::CONFIG.load(deps.storage)?; + cw_ownable::initialize_owner( + deps.storage, + deps.api, + config.owner.map(|a| a.into_string()).as_deref(), + )?; + let config = Config { + token_address: config.token_address, + unstaking_duration: config.unstaking_duration.map(|duration| match duration { + cw_utils_v1::Duration::Time(t) => Duration::Time(t), + cw_utils_v1::Duration::Height(h) => Duration::Height(h), + }), + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default()) + } + } +} diff --git a/contracts/staking/cw20-stake/src/error.rs b/contracts/staking/cw20-stake/src/error.rs new file mode 100644 index 000000000..cd7140a21 --- /dev/null +++ b/contracts/staking/cw20-stake/src/error.rs @@ -0,0 +1,33 @@ +use cosmwasm_std::{Addr, StdError}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + #[error(transparent)] + Cw20Error(#[from] cw20_base::ContractError), + #[error(transparent)] + Ownership(#[from] cw_ownable::OwnershipError), + #[error(transparent)] + HookError(#[from] cw_controllers::HookError), + + #[error("Provided cw20 errored in response to TokenInfo query")] + InvalidCw20 {}, + #[error("Nothing to claim")] + NothingToClaim {}, + #[error("Nothing to unstake")] + NothingStaked {}, + #[error("Unstaking this amount violates the invariant: (cw20 total_supply <= 2^128)")] + Cw20InvaraintViolation {}, + #[error("Can not unstake more than has been staked")] + ImpossibleUnstake {}, + #[error("Invalid token")] + InvalidToken { received: Addr, expected: Addr }, + #[error("Too many outstanding claims. Claim some tokens before unstaking more.")] + TooManyClaims {}, + #[error("Invalid unstaking duration, unstaking duration cannot be 0")] + InvalidUnstakingDuration {}, + #[error("can not migrate. current version is up to date")] + AlreadyMigrated {}, +} diff --git a/contracts/staking/cw20-stake/src/hooks.rs b/contracts/staking/cw20-stake/src/hooks.rs new file mode 100644 index 000000000..5867d8ff4 --- /dev/null +++ b/contracts/staking/cw20-stake/src/hooks.rs @@ -0,0 +1,52 @@ +use crate::state::HOOKS; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, Addr, StdResult, Storage, SubMsg, Uint128, WasmMsg}; + +// This is just a helper to properly serialize the above message +#[cw_serde] +pub enum StakeChangedHookMsg { + Stake { addr: Addr, amount: Uint128 }, + Unstake { addr: Addr, amount: Uint128 }, +} + +pub fn stake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + amount: Uint128, +) -> StdResult> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Stake { addr, amount }, + ))?; + HOOKS.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::new(execute)) + }) +} + +pub fn unstake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + amount: Uint128, +) -> StdResult> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Unstake { addr, amount }, + ))?; + HOOKS.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::new(execute)) + }) +} + +// This is just a helper to properly serialize the above message +#[cw_serde] +enum StakeChangedExecuteMsg { + StakeChangeHook(StakeChangedHookMsg), +} diff --git a/contracts/staking/cw20-stake/src/lib.rs b/contracts/staking/cw20-stake/src/lib.rs new file mode 100644 index 000000000..6688af2cd --- /dev/null +++ b/contracts/staking/cw20-stake/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod hooks; +mod math; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/staking/cw20-stake/src/math.rs b/contracts/staking/cw20-stake/src/math.rs new file mode 100644 index 000000000..879a09735 --- /dev/null +++ b/contracts/staking/cw20-stake/src/math.rs @@ -0,0 +1,139 @@ +use std::{convert::TryInto, ops::Div}; + +use cosmwasm_std::{Uint128, Uint256}; + +/// Computes the amount to add to an address' staked balance when +/// staking. +/// +/// # Arguments +/// +/// * `staked_total` - The number of tokens that have been staked. +/// * `balance` - The number of tokens the contract has (staked_total + rewards). +/// * `sent` - The number of tokens the user has sent to be staked. +pub(crate) fn amount_to_stake(staked_total: Uint128, balance: Uint128, sent: Uint128) -> Uint128 { + if staked_total.is_zero() || balance.is_zero() { + sent + } else { + staked_total + .full_mul(sent) + .div(Uint256::from(balance)) + .try_into() + .unwrap() // balance := staked_total + rewards + // => balance >= staked_total + // => staked_total / balance <= 1 + // => staked_total * sent / balance <= sent + // => we can safely unwrap here as sent fits into a u128 by construction. + } +} + +/// Computes the number of tokens to return to an address when +/// claiming. +/// +/// # Arguments +/// +/// * `staked_total` - The number of tokens that have been staked. +/// * `balance` - The number of tokens the contract has (staked_total + rewards). +/// * `ask` - The number of tokens being claimed. +/// +/// # Invariants +/// +/// These must be checked by the caller. If checked, this function is +/// guarenteed not to panic. +/// +/// 1. staked_total != 0. +/// 2. ask + balance <= 2^128 +/// 3. ask <= staked_total +/// +/// For information on the panic conditions for math, see: +/// +pub(crate) fn amount_to_claim(staked_total: Uint128, balance: Uint128, ask: Uint128) -> Uint128 { + // we know that: + // + // 1. cw20's max supply is 2^128 + // 2. balance := staked_total + rewards + // + // for non-malicious inputs: + // + // 3. 1 => ask + balance <= 2^128 + // 4. ask <= staked_total + // 5. staked_total != 0 + // 6. 4 => ask / staked_total <= 1 + // 7. 3 => balance <= 2^128 + // 8. 6 + 7 => ask / staked_total * balance <= 2^128 + // + // which, as addition and division are communative, proves that + // ask * balance / staked_total will fit into a 128 bit integer. + ask.full_mul(balance) + .div(Uint256::from(staked_total)) + .try_into() + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_amount_to_stake_no_overflow() { + let sent = Uint128::new(2); + let balance = Uint128::MAX - sent; + + let overflows_naively = sent.checked_mul(balance).is_err(); + assert!(overflows_naively); + + // will panic and fail the test if we've done this wrong. + amount_to_stake(balance, balance, sent); + } + + #[test] + fn test_amount_to_stake_with_zeros() { + let sent = Uint128::new(42); + let balance = Uint128::zero(); + let amount = amount_to_stake(balance, balance, sent); + assert_eq!(amount, sent); + } + + #[test] + fn test_amount_to_claim_no_overflow() { + let ask = Uint128::new(2); + let balance = Uint128::MAX - ask; + + let overflows_naively = ask.checked_mul(balance).is_err(); + assert!(overflows_naively); + + amount_to_claim(balance, balance, ask); + } + + // check that our invariants are indeed invariants. + + #[test] + #[should_panic(expected = "attempt to divide by zero")] + fn test_amount_to_claim_invariant_one() { + let ask = Uint128::new(2); + let balance = Uint128::zero(); + + amount_to_claim(balance, balance, ask); + } + + #[test] + #[should_panic(expected = "ConversionOverflowError")] + fn test_amount_to_claim_invariant_two() { + // Could end up in a situation like this if there are a lot of + // rewards, but very few staked tokens. + let ask = Uint128::new(2); + let balance = Uint128::MAX; + let staked_total = Uint128::new(1); + + amount_to_claim(staked_total, balance, ask); + } + + #[test] + #[should_panic(expected = "ConversionOverflowError")] + fn test_amount_to_claim_invariant_three() { + let ask = Uint128::new(2); + let balance = Uint128::MAX; + let staked_total = Uint128::new(1); + + amount_to_claim(staked_total, balance, ask); + } +} diff --git a/contracts/staking/cw20-stake/src/msg.rs b/contracts/staking/cw20-stake/src/msg.rs new file mode 100644 index 000000000..bfe65466b --- /dev/null +++ b/contracts/staking/cw20-stake/src/msg.rs @@ -0,0 +1,112 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; +use cw20::Cw20ReceiveMsg; + +use cw_utils::Duration; + +use cw_ownable::cw_ownable_execute; + +pub use cw_controllers::ClaimsResponse; +// so that consumers don't need a cw_ownable dependency to consume +// this contract's queries. +pub use cw_ownable::Ownership; + +#[cw_serde] +pub struct InstantiateMsg { + // Owner can update all configs including changing the owner. This will generally be a DAO. + pub owner: Option, + pub token_address: String, + pub unstaking_duration: Option, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + Receive(Cw20ReceiveMsg), + Unstake { amount: Uint128 }, + Claim {}, + UpdateConfig { duration: Option }, + AddHook { addr: String }, + RemoveHook { addr: String }, +} + +#[cw_serde] +pub enum ReceiveMsg { + Stake {}, + Fund {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(StakedBalanceAtHeightResponse)] + StakedBalanceAtHeight { + address: String, + height: Option, + }, + #[returns(TotalStakedAtHeightResponse)] + TotalStakedAtHeight { height: Option }, + #[returns(StakedValueResponse)] + StakedValue { address: String }, + #[returns(TotalValueResponse)] + TotalValue {}, + #[returns(crate::state::Config)] + GetConfig {}, + #[returns(ClaimsResponse)] + Claims { address: String }, + #[returns(GetHooksResponse)] + GetHooks {}, + #[returns(ListStakersResponse)] + ListStakers { + start_after: Option, + limit: Option, + }, + #[returns(::cw_ownable::Ownership::<::cosmwasm_std::Addr>)] + Ownership {}, +} + +#[cw_serde] +pub enum MigrateMsg { + /// Migrates the contract from version one to version two. This + /// will remove the contract's current manager, and require a + /// nomination -> acceptance flow for future ownership transfers. + FromV1 {}, +} + +#[cw_serde] +pub struct StakedBalanceAtHeightResponse { + pub balance: Uint128, + pub height: u64, +} + +#[cw_serde] +pub struct TotalStakedAtHeightResponse { + pub total: Uint128, + pub height: u64, +} + +#[cw_serde] +pub struct StakedValueResponse { + pub value: Uint128, +} + +#[cw_serde] +pub struct TotalValueResponse { + pub total: Uint128, +} + +#[cw_serde] +pub struct GetHooksResponse { + pub hooks: Vec, +} + +#[cw_serde] +pub struct ListStakersResponse { + pub stakers: Vec, +} + +#[cw_serde] +pub struct StakerBalanceResponse { + pub address: String, + pub balance: Uint128, +} diff --git a/contracts/staking/cw20-stake/src/state.rs b/contracts/staking/cw20-stake/src/state.rs new file mode 100644 index 000000000..2ebb1c887 --- /dev/null +++ b/contracts/staking/cw20-stake/src/state.rs @@ -0,0 +1,39 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_controllers::Claims; +use cw_controllers::Hooks; +use cw_storage_plus::{Item, SnapshotItem, SnapshotMap, Strategy}; +use cw_utils::Duration; + +#[cw_serde] +pub struct Config { + pub token_address: Addr, + pub unstaking_duration: Option, +} + +// `"config"` key stores v1 configuration. +pub const CONFIG: Item = Item::new("config_v2"); + +pub const STAKED_BALANCES: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "staked_balances", + "staked_balance__checkpoints", + "staked_balance__changelog", + Strategy::EveryBlock, +); + +pub const STAKED_TOTAL: SnapshotItem = SnapshotItem::new( + "total_staked", + "total_staked__checkpoints", + "total_staked__changelog", + Strategy::EveryBlock, +); + +/// The maximum number of claims that may be outstanding. +pub const MAX_CLAIMS: u64 = 100; + +pub const CLAIMS: Claims = Claims::new("claims"); + +pub const BALANCE: Item = Item::new("balance"); + +// Hooks to contracts that will receive staking and unstaking messages +pub const HOOKS: Hooks = Hooks::new("hooks"); diff --git a/contracts/staking/cw20-stake/src/tests.rs b/contracts/staking/cw20-stake/src/tests.rs new file mode 100644 index 000000000..81ae03b1a --- /dev/null +++ b/contracts/staking/cw20-stake/src/tests.rs @@ -0,0 +1,1192 @@ +use std::borrow::BorrowMut; + +use crate::msg::{ + ExecuteMsg, ListStakersResponse, MigrateMsg, QueryMsg, ReceiveMsg, + StakedBalanceAtHeightResponse, StakedValueResponse, StakerBalanceResponse, + TotalStakedAtHeightResponse, TotalValueResponse, +}; +use crate::state::{Config, MAX_CLAIMS}; +use crate::ContractError; +use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; +use cosmwasm_std::{to_binary, Addr, Empty, MessageInfo, Uint128, WasmMsg}; +use cw20::Cw20Coin; +use cw_ownable::{Action, Ownership, OwnershipError}; +use cw_utils::Duration; + +use cw_multi_test::{next_block, App, AppResponse, Contract, ContractWrapper, Executor}; + +use anyhow::Result as AnyResult; +use cw20_stake_v1 as v1; + +use cw_controllers::{Claim, ClaimsResponse}; +use cw_utils::Expiration::AtHeight; + +const ADDR1: &str = "addr0001"; +const ADDR2: &str = "addr0002"; +const ADDR3: &str = "addr0003"; +const ADDR4: &str = "addr0004"; +const OWNER: &str = "owner"; + +fn contract_staking() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +fn contract_staking_v1() -> Box> { + let contract = ContractWrapper::new( + v1::contract::execute, + v1::contract::instantiate, + v1::contract::query, + ) + .with_migrate(v1::contract::migrate); + Box::new(contract) +} + +fn contract_cw20() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn mock_app() -> App { + App::default() +} + +fn get_balance, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn instantiate_cw20(app: &mut App, initial_balances: Vec) -> Addr { + let cw20_id = app.store_code(contract_cw20()); + let msg = cw20_base::msg::InstantiateMsg { + name: String::from("Test"), + symbol: String::from("TEST"), + decimals: 6, + initial_balances, + mint: None, + marketing: None, + }; + + app.instantiate_contract(cw20_id, Addr::unchecked(ADDR1), &msg, &[], "cw20", None) + .unwrap() +} + +fn instantiate_staking(app: &mut App, cw20: Addr, unstaking_duration: Option) -> Addr { + let staking_code_id = app.store_code(contract_staking()); + let msg = crate::msg::InstantiateMsg { + owner: Some(OWNER.to_string()), + token_address: cw20.to_string(), + unstaking_duration, + }; + app.instantiate_contract( + staking_code_id, + Addr::unchecked(ADDR1), + &msg, + &[], + "staking", + Some("admin".to_string()), + ) + .unwrap() +} + +fn setup_test_case( + app: &mut App, + initial_balances: Vec, + unstaking_duration: Option, +) -> (Addr, Addr) { + // Instantiate cw20 contract + let cw20_addr = instantiate_cw20(app, initial_balances); + app.update_block(next_block); + // Instantiate staking contract + let staking_addr = instantiate_staking(app, cw20_addr.clone(), unstaking_duration); + app.update_block(next_block); + (staking_addr, cw20_addr) +} + +fn query_staked_balance, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = QueryMsg::StakedBalanceAtHeight { + address: address.into(), + height: None, + }; + let result: StakedBalanceAtHeightResponse = + app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.balance +} + +fn query_config>(app: &App, contract_addr: T) -> Config { + let msg = QueryMsg::GetConfig {}; + app.wrap().query_wasm_smart(contract_addr, &msg).unwrap() +} + +fn query_owner>(app: &App, contract: T) -> Ownership { + app.wrap() + .query_wasm_smart(contract, &QueryMsg::Ownership {}) + .unwrap() +} + +fn query_total_staked>(app: &App, contract_addr: T) -> Uint128 { + let msg = QueryMsg::TotalStakedAtHeight { height: None }; + let result: TotalStakedAtHeightResponse = + app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.total +} + +fn query_staked_value, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Uint128 { + let msg = QueryMsg::StakedValue { + address: address.into(), + }; + let result: StakedValueResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.value +} + +fn query_total_value>(app: &App, contract_addr: T) -> Uint128 { + let msg = QueryMsg::TotalValue {}; + let result: TotalValueResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.total +} + +fn query_claims, U: Into>( + app: &App, + contract_addr: T, + address: U, +) -> Vec { + let msg = QueryMsg::Claims { + address: address.into(), + }; + let result: ClaimsResponse = app.wrap().query_wasm_smart(contract_addr, &msg).unwrap(); + result.claims +} + +fn stake_tokens( + app: &mut App, + staking_addr: &Addr, + cw20_addr: &Addr, + info: MessageInfo, + amount: Uint128, +) -> AnyResult { + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount, + msg: to_binary(&ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(info.sender, cw20_addr.clone(), &msg, &[]) +} + +fn update_config( + app: &mut App, + staking_addr: &Addr, + info: MessageInfo, + duration: Option, +) -> AnyResult { + let msg = ExecuteMsg::UpdateConfig { duration }; + app.execute_contract(info.sender, staking_addr.clone(), &msg, &[]) +} + +fn unstake_tokens( + app: &mut App, + staking_addr: &Addr, + info: MessageInfo, + amount: Uint128, +) -> AnyResult { + let msg = ExecuteMsg::Unstake { amount }; + app.execute_contract(info.sender, staking_addr.clone(), &msg, &[]) +} + +fn claim_tokens(app: &mut App, staking_addr: &Addr, info: MessageInfo) -> AnyResult { + let msg = ExecuteMsg::Claim {}; + app.execute_contract(info.sender, staking_addr.clone(), &msg, &[]) +} + +#[test] +#[should_panic(expected = "Invalid unstaking duration, unstaking duration cannot be 0")] +fn test_instantiate_invalid_unstaking_duration() { + let mut app = mock_app(); + let amount1 = Uint128::from(100u128); + let _token_address = Addr::unchecked("token_address"); + let initial_balances = vec![Cw20Coin { + address: ADDR1.to_string(), + amount: amount1, + }]; + let (_staking_addr, _cw20_addr) = + setup_test_case(&mut app, initial_balances, Some(Duration::Height(0))); +} + +#[test] +#[should_panic(expected = "Provided cw20 errored in response to TokenInfo query")] +fn test_instantiate_with_non_cw20_token() { + let app = &mut mock_app(); + instantiate_staking(app, Addr::unchecked("ekez"), None); +} + +#[test] +fn test_update_config() { + let mut app = mock_app(); + let amount1 = Uint128::from(100u128); + let initial_balances = vec![Cw20Coin { + address: ADDR1.to_string(), + amount: amount1, + }]; + let (staking_addr, _cw20_addr) = setup_test_case(&mut app, initial_balances, None); + + // Owner can update configuration. + let info = mock_info(OWNER, &[]); + update_config(&mut app, &staking_addr, info, Some(Duration::Height(1234))).unwrap(); + let config = query_config(&app, &staking_addr); + assert_eq!(config.unstaking_duration, Some(Duration::Height(1234))); + + // Non owner may not update configuration. + let info = mock_info(ADDR1, &[]); + let err: ContractError = update_config(&mut app, &staking_addr, info, None) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Ownership(OwnershipError::NotOwner)); + + // Zero durations not allowed. + let info = mock_info(OWNER, &[]); + let err: ContractError = + update_config(&mut app, &staking_addr, info, Some(Duration::Height(0))) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidUnstakingDuration {}); + + let info = mock_info(OWNER, &[]); + let err: ContractError = update_config(&mut app, &staking_addr, info, Some(Duration::Time(0))) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidUnstakingDuration {}); +} + +#[test] +fn test_staking() { + let _deps = mock_dependencies(); + + let mut app = mock_app(); + let amount1 = Uint128::from(100u128); + let _token_address = Addr::unchecked("token_address"); + let initial_balances = vec![Cw20Coin { + address: ADDR1.to_string(), + amount: amount1, + }]; + let (staking_addr, cw20_addr) = setup_test_case(&mut app, initial_balances, None); + + let info = mock_info(ADDR1, &[]); + let _env = mock_env(); + + // Successful bond + let amount = Uint128::new(50); + stake_tokens(&mut app, &staking_addr, &cw20_addr, info.clone(), amount).unwrap(); + + // Very important that this balances is not reflected until + // the next block. This protects us from flash loan hostile + // takeovers. + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::zero() + ); + + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(50u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(50u128) + ); + assert_eq!( + get_balance(&app, &cw20_addr, ADDR1.to_string()), + Uint128::from(50u128) + ); + + // Can't transfer bonded amount + let msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: ADDR2.to_string(), + amount: Uint128::from(51u128), + }; + let _err = app + .borrow_mut() + .execute_contract(info.sender.clone(), cw20_addr.clone(), &msg, &[]) + .unwrap_err(); + + // Sucessful transfer of unbonded amount + let msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: ADDR2.to_string(), + amount: Uint128::from(20u128), + }; + let _res = app + .borrow_mut() + .execute_contract(info.sender, cw20_addr.clone(), &msg, &[]) + .unwrap(); + + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(30u128)); + assert_eq!(get_balance(&app, &cw20_addr, ADDR2), Uint128::from(20u128)); + + // Addr 2 successful bond + let info = mock_info(ADDR2, &[]); + stake_tokens(&mut app, &staking_addr, &cw20_addr, info, Uint128::new(20)).unwrap(); + + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2), + Uint128::from(20u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(70u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR2), Uint128::zero()); + + // Can't unstake more than you have staked + let info = mock_info(ADDR2, &[]); + let _err = unstake_tokens(&mut app, &staking_addr, info, Uint128::new(100)).unwrap_err(); + + // Successful unstake + let info = mock_info(ADDR2, &[]); + let _res = unstake_tokens(&mut app, &staking_addr, info, Uint128::new(10)).unwrap(); + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2), + Uint128::from(10u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(60u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR2), Uint128::from(10u128)); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1), + Uint128::from(50u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(30u128)); +} + +#[test] +fn text_max_claims() { + let mut app = mock_app(); + let amount1 = Uint128::from(MAX_CLAIMS + 1); + let unstaking_blocks = 1u64; + let _token_address = Addr::unchecked("token_address"); + let initial_balances = vec![Cw20Coin { + address: ADDR1.to_string(), + amount: amount1, + }]; + let (staking_addr, cw20_addr) = setup_test_case( + &mut app, + initial_balances, + Some(Duration::Height(unstaking_blocks)), + ); + + let info = mock_info(ADDR1, &[]); + stake_tokens(&mut app, &staking_addr, &cw20_addr, info.clone(), amount1).unwrap(); + + // Create the max number of claims + for _ in 0..MAX_CLAIMS { + unstake_tokens(&mut app, &staking_addr, info.clone(), Uint128::new(1)).unwrap(); + } + + // Additional unstaking attempts ought to fail. + unstake_tokens(&mut app, &staking_addr, info.clone(), Uint128::new(1)).unwrap_err(); + + // Clear out the claims list. + app.update_block(next_block); + claim_tokens(&mut app, &staking_addr, info.clone()).unwrap(); + + // Unstaking now allowed again. + unstake_tokens(&mut app, &staking_addr, info.clone(), Uint128::new(1)).unwrap(); + app.update_block(next_block); + claim_tokens(&mut app, &staking_addr, info).unwrap(); + + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), amount1); +} + +#[test] +fn test_unstaking_with_claims() { + let _deps = mock_dependencies(); + + let mut app = mock_app(); + let amount1 = Uint128::from(100u128); + let unstaking_blocks = 10u64; + let _token_address = Addr::unchecked("token_address"); + let initial_balances = vec![Cw20Coin { + address: ADDR1.to_string(), + amount: amount1, + }]; + let (staking_addr, cw20_addr) = setup_test_case( + &mut app, + initial_balances, + Some(Duration::Height(unstaking_blocks)), + ); + + let info = mock_info(ADDR1, &[]); + + // Successful bond + let _res = stake_tokens(&mut app, &staking_addr, &cw20_addr, info, Uint128::new(50)).unwrap(); + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1), + Uint128::from(50u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(50u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(50u128)); + + // Unstake + let info = mock_info(ADDR1, &[]); + let _res = unstake_tokens(&mut app, &staking_addr, info, Uint128::new(10)).unwrap(); + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1), + Uint128::from(40u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(40u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(50u128)); + + // Cannot claim when nothing is available + let info = mock_info(ADDR1, &[]); + let _err: ContractError = claim_tokens(&mut app, &staking_addr, info) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(_err, ContractError::NothingToClaim {}); + + // Successful claim + app.update_block(|b| b.height += unstaking_blocks); + let info = mock_info(ADDR1, &[]); + let _res = claim_tokens(&mut app, &staking_addr, info).unwrap(); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1), + Uint128::from(40u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(40u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(60u128)); + + // Unstake and claim multiple + let _info = mock_info(ADDR1, &[]); + let info = mock_info(ADDR1, &[]); + let _res = unstake_tokens(&mut app, &staking_addr, info, Uint128::new(5)).unwrap(); + app.update_block(next_block); + + let _info = mock_info(ADDR1, &[]); + let info = mock_info(ADDR1, &[]); + let _res = unstake_tokens(&mut app, &staking_addr, info, Uint128::new(5)).unwrap(); + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1), + Uint128::from(30u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(30u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(60u128)); + + app.update_block(|b| b.height += unstaking_blocks); + let info = mock_info(ADDR1, &[]); + let _res = claim_tokens(&mut app, &staking_addr, info).unwrap(); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1), + Uint128::from(30u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(30u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(70u128)); +} + +#[test] +fn multiple_address_staking() { + let amount1 = Uint128::from(100u128); + let initial_balances = vec![ + Cw20Coin { + address: ADDR1.to_string(), + amount: amount1, + }, + Cw20Coin { + address: ADDR2.to_string(), + amount: amount1, + }, + Cw20Coin { + address: ADDR3.to_string(), + amount: amount1, + }, + Cw20Coin { + address: ADDR4.to_string(), + amount: amount1, + }, + ]; + let mut app = mock_app(); + let amount1 = Uint128::from(100u128); + let unstaking_blocks = 10u64; + let _token_address = Addr::unchecked("token_address"); + let (staking_addr, cw20_addr) = setup_test_case( + &mut app, + initial_balances, + Some(Duration::Height(unstaking_blocks)), + ); + + let info = mock_info(ADDR1, &[]); + // Successful bond + let _res = stake_tokens(&mut app, &staking_addr, &cw20_addr, info, amount1).unwrap(); + app.update_block(next_block); + + let info = mock_info(ADDR2, &[]); + // Successful bond + let _res = stake_tokens(&mut app, &staking_addr, &cw20_addr, info, amount1).unwrap(); + app.update_block(next_block); + + let info = mock_info(ADDR3, &[]); + // Successful bond + let _res = stake_tokens(&mut app, &staking_addr, &cw20_addr, info, amount1).unwrap(); + app.update_block(next_block); + + let info = mock_info(ADDR4, &[]); + // Successful bond + let _res = stake_tokens(&mut app, &staking_addr, &cw20_addr, info, amount1).unwrap(); + app.update_block(next_block); + + assert_eq!(query_staked_balance(&app, &staking_addr, ADDR1), amount1); + assert_eq!(query_staked_balance(&app, &staking_addr, ADDR2), amount1); + assert_eq!(query_staked_balance(&app, &staking_addr, ADDR3), amount1); + assert_eq!(query_staked_balance(&app, &staking_addr, ADDR4), amount1); + + assert_eq!( + query_total_staked(&app, &staking_addr), + amount1.checked_mul(Uint128::new(4)).unwrap() + ); + + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::zero()); + assert_eq!(get_balance(&app, &cw20_addr, ADDR2), Uint128::zero()); + assert_eq!(get_balance(&app, &cw20_addr, ADDR3), Uint128::zero()); + assert_eq!(get_balance(&app, &cw20_addr, ADDR4), Uint128::zero()); +} + +#[test] +fn test_auto_compounding_staking() { + let _deps = mock_dependencies(); + + let mut app = mock_app(); + let amount1 = Uint128::from(1000u128); + let _token_address = Addr::unchecked("token_address"); + let initial_balances = vec![Cw20Coin { + address: ADDR1.to_string(), + amount: amount1, + }]; + let (staking_addr, cw20_addr) = setup_test_case(&mut app, initial_balances, None); + + let info = mock_info(ADDR1, &[]); + let _env = mock_env(); + + // Successful bond + let amount = Uint128::new(100); + stake_tokens(&mut app, &staking_addr, &cw20_addr, info, amount).unwrap(); + app.update_block(next_block); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(100u128) + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128) + ); + assert_eq!( + query_total_value(&app, &staking_addr), + Uint128::from(100u128) + ); + assert_eq!( + get_balance(&app, &cw20_addr, ADDR1.to_string()), + Uint128::from(900u128) + ); + + // Add compounding rewards + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::from(100u128), + msg: to_binary(&ReceiveMsg::Fund {}).unwrap(), + }; + let _res = app + .borrow_mut() + .execute_contract(Addr::unchecked(ADDR1), cw20_addr.clone(), &msg, &[]) + .unwrap(); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(100u128) + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(200u128) + ); + assert_eq!( + query_total_value(&app, &staking_addr), + Uint128::from(200u128) + ); + assert_eq!( + get_balance(&app, &cw20_addr, ADDR1.to_string()), + Uint128::from(800u128) + ); + + // Sucessful transfer of unbonded amount + let msg = cw20::Cw20ExecuteMsg::Transfer { + recipient: ADDR2.to_string(), + amount: Uint128::from(100u128), + }; + let _res = app + .borrow_mut() + .execute_contract(Addr::unchecked(ADDR1), cw20_addr.clone(), &msg, &[]) + .unwrap(); + + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(700u128)); + assert_eq!(get_balance(&app, &cw20_addr, ADDR2), Uint128::from(100u128)); + + // Addr 2 successful bond + let info = mock_info(ADDR2, &[]); + stake_tokens(&mut app, &staking_addr, &cw20_addr, info, Uint128::new(100)).unwrap(); + + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2), + Uint128::from(50u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(150u128) + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR2.to_string()), + Uint128::from(100u128) + ); + assert_eq!( + query_total_value(&app, &staking_addr), + Uint128::from(300u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR2), Uint128::zero()); + + // Can't unstake more than you have staked + let info = mock_info(ADDR2, &[]); + let _err = unstake_tokens(&mut app, &staking_addr, info, Uint128::new(51)).unwrap_err(); + + // Add compounding rewards + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::from(90u128), + msg: to_binary(&ReceiveMsg::Fund {}).unwrap(), + }; + let _res = app + .borrow_mut() + .execute_contract(Addr::unchecked(ADDR1), cw20_addr.clone(), &msg, &[]) + .unwrap(); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128) + ); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2), + Uint128::from(50u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(150u128) + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(260u128) + ); + assert_eq!( + query_staked_value(&app, &staking_addr, ADDR2.to_string()), + Uint128::from(130u128) + ); + assert_eq!( + query_total_value(&app, &staking_addr), + Uint128::from(390u128) + ); + assert_eq!( + get_balance(&app, &cw20_addr, ADDR1.to_string()), + Uint128::from(610u128) + ); + + // Successful unstake + let info = mock_info(ADDR2, &[]); + let _res = unstake_tokens(&mut app, &staking_addr, info, Uint128::new(25)).unwrap(); + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2), + Uint128::from(25u128) + ); + assert_eq!( + query_total_staked(&app, &staking_addr), + Uint128::from(125u128) + ); + assert_eq!(get_balance(&app, &cw20_addr, ADDR2), Uint128::from(65u128)); +} + +#[test] +fn test_simple_unstaking_with_duration() { + let _deps = mock_dependencies(); + + let mut app = mock_app(); + let amount1 = Uint128::from(100u128); + let _token_address = Addr::unchecked("token_address"); + let initial_balances = vec![ + Cw20Coin { + address: ADDR1.to_string(), + amount: amount1, + }, + Cw20Coin { + address: ADDR2.to_string(), + amount: amount1, + }, + ]; + let (staking_addr, cw20_addr) = + setup_test_case(&mut app, initial_balances, Some(Duration::Height(1))); + + // Bond Address 1 + let info = mock_info(ADDR1, &[]); + let _env = mock_env(); + let amount = Uint128::new(100); + stake_tokens(&mut app, &staking_addr, &cw20_addr, info, amount).unwrap(); + + // Bond Address 2 + let info = mock_info(ADDR2, &[]); + let _env = mock_env(); + let amount = Uint128::new(100); + stake_tokens(&mut app, &staking_addr, &cw20_addr, info, amount).unwrap(); + app.update_block(next_block); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128) + ); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(100u128) + ); + + // Unstake Addr1 + let info = mock_info(ADDR1, &[]); + let _env = mock_env(); + let amount = Uint128::new(100); + unstake_tokens(&mut app, &staking_addr, info, amount).unwrap(); + + // Unstake Addr2 + let info = mock_info(ADDR2, &[]); + let _env = mock_env(); + let amount = Uint128::new(100); + unstake_tokens(&mut app, &staking_addr, info, amount).unwrap(); + + app.update_block(next_block); + + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR1.to_string()), + Uint128::from(0u128) + ); + assert_eq!( + query_staked_balance(&app, &staking_addr, ADDR2.to_string()), + Uint128::from(0u128) + ); + + // Claim + assert_eq!( + query_claims(&app, &staking_addr, ADDR1), + vec![Claim { + amount: Uint128::new(100), + release_at: AtHeight(12349) + }] + ); + assert_eq!( + query_claims(&app, &staking_addr, ADDR2), + vec![Claim { + amount: Uint128::new(100), + release_at: AtHeight(12349) + }] + ); + + let info = mock_info(ADDR1, &[]); + claim_tokens(&mut app, &staking_addr, info).unwrap(); + assert_eq!(get_balance(&app, &cw20_addr, ADDR1), Uint128::from(100u128)); + + let info = mock_info(ADDR2, &[]); + claim_tokens(&mut app, &staking_addr, info).unwrap(); + assert_eq!(get_balance(&app, &cw20_addr, ADDR2), Uint128::from(100u128)); +} + +#[test] +fn test_double_unstake_at_height() { + let mut app = App::default(); + + let (staking_addr, cw20_addr) = setup_test_case( + &mut app, + vec![Cw20Coin { + address: "ekez".to_string(), + amount: Uint128::new(10), + }], + None, + ); + + stake_tokens( + &mut app, + &staking_addr, + &cw20_addr, + mock_info("ekez", &[]), + Uint128::new(10), + ) + .unwrap(); + + app.update_block(next_block); + + unstake_tokens( + &mut app, + &staking_addr, + mock_info("ekez", &[]), + Uint128::new(1), + ) + .unwrap(); + + unstake_tokens( + &mut app, + &staking_addr, + mock_info("ekez", &[]), + Uint128::new(9), + ) + .unwrap(); + + app.update_block(next_block); + + // Unstaked balances are not reflected until the following + // block. Same behavior as staked balances. This is important + // because otherwise weird things could happen like: + // + // 1. I create a proposal (and am allowed to because I have a + // staked balance) + // 2. I unstake all my tokens in the same block. + // + // Now there is some strangeness as for part of the block I had a + // staked balance and was allowed to take actions as if I did, and + // part of it I did not. + let balance: StakedBalanceAtHeightResponse = app + .wrap() + .query_wasm_smart( + staking_addr.clone(), + &QueryMsg::StakedBalanceAtHeight { + address: "ekez".to_string(), + height: Some(app.block_info().height - 1), + }, + ) + .unwrap(); + + assert_eq!(balance.balance, Uint128::new(10)); + + let balance: StakedBalanceAtHeightResponse = app + .wrap() + .query_wasm_smart( + staking_addr, + &QueryMsg::StakedBalanceAtHeight { + address: "ekez".to_string(), + height: Some(app.block_info().height), + }, + ) + .unwrap(); + + assert_eq!(balance.balance, Uint128::zero()) +} + +#[test] +fn test_query_list_stakers() { + let mut app = App::default(); + + let (staking_addr, cw20_addr) = setup_test_case( + &mut app, + vec![ + Cw20Coin { + address: "ekez1".to_string(), + amount: Uint128::new(10), + }, + Cw20Coin { + address: "ekez2".to_string(), + amount: Uint128::new(20), + }, + Cw20Coin { + address: "ekez3".to_string(), + amount: Uint128::new(30), + }, + Cw20Coin { + address: "ekez4".to_string(), + amount: Uint128::new(40), + }, + ], + None, + ); + + stake_tokens( + &mut app, + &staking_addr, + &cw20_addr, + mock_info("ekez1", &[]), + Uint128::new(10), + ) + .unwrap(); + + stake_tokens( + &mut app, + &staking_addr, + &cw20_addr, + mock_info("ekez2", &[]), + Uint128::new(20), + ) + .unwrap(); + + stake_tokens( + &mut app, + &staking_addr, + &cw20_addr, + mock_info("ekez3", &[]), + Uint128::new(30), + ) + .unwrap(); + + stake_tokens( + &mut app, + &staking_addr, + &cw20_addr, + mock_info("ekez4", &[]), + Uint128::new(40), + ) + .unwrap(); + + // check first 2 + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + staking_addr.clone(), + &QueryMsg::ListStakers { + start_after: None, + limit: Some(2), + }, + ) + .unwrap(); + + let test_res = ListStakersResponse { + stakers: vec![ + StakerBalanceResponse { + address: "ekez1".to_string(), + balance: Uint128::new(10), + }, + StakerBalanceResponse { + address: "ekez2".to_string(), + balance: Uint128::new(20), + }, + ], + }; + + assert_eq!(stakers, test_res); + + // skip first and grab 2 + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + staking_addr, + &QueryMsg::ListStakers { + start_after: Some("ekez1".to_string()), + limit: Some(2), + }, + ) + .unwrap(); + + let test_res = ListStakersResponse { + stakers: vec![ + StakerBalanceResponse { + address: "ekez2".to_string(), + balance: Uint128::new(20), + }, + StakerBalanceResponse { + address: "ekez3".to_string(), + balance: Uint128::new(30), + }, + ], + }; + + assert_eq!(stakers, test_res) +} + +#[test] +fn test_ownership_transfer() { + let mut app = App::default(); + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::from(1000u64), + }], + ); + let staking_addr = instantiate_staking(&mut app, cw20_addr, None); + + app.execute_contract( + Addr::unchecked(OWNER), + staking_addr.clone(), + &ExecuteMsg::UpdateOwnership(Action::TransferOwnership { + new_owner: ADDR1.to_string(), + expiry: None, + }), + &[], + ) + .unwrap(); + + let ownership = query_owner(&app, &staking_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(OWNER)), + pending_owner: Some(Addr::unchecked(ADDR1)), + pending_expiry: None + } + ); + + app.execute_contract( + Addr::unchecked(ADDR1), + staking_addr.clone(), + &ExecuteMsg::UpdateOwnership(Action::AcceptOwnership), + &[], + ) + .unwrap(); + + let ownership = query_owner(&app, &staking_addr); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(ADDR1)), + pending_owner: None, + pending_expiry: None + } + ); +} + +#[test] +fn test_migrate_from_v1() { + let mut app = App::default(); + let cw20_addr = instantiate_cw20( + &mut app, + vec![cw20::Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::from(1000u64), + }], + ); + + let v1_code = app.store_code(contract_staking_v1()); + let v2_code = app.store_code(contract_staking()); + + let staking = app + .instantiate_contract( + v1_code, + Addr::unchecked(OWNER), + &v1::msg::InstantiateMsg { + owner: Some(OWNER.to_string()), + manager: Some(OWNER.to_string()), + token_address: cw20_addr.to_string(), + unstaking_duration: None, + }, + &[], + "staking".to_string(), + Some(OWNER.to_string()), + ) + .unwrap(); + + app.execute( + Addr::unchecked(OWNER), + WasmMsg::Migrate { + contract_addr: staking.to_string(), + new_code_id: v2_code, + msg: to_binary(&MigrateMsg::FromV1 {}).unwrap(), + } + .into(), + ) + .unwrap(); + + // can not migrate more than once. + let err: ContractError = app + .execute( + Addr::unchecked(OWNER), + WasmMsg::Migrate { + contract_addr: staking.to_string(), + new_code_id: v2_code, + msg: to_binary(&MigrateMsg::FromV1 {}).unwrap(), + } + .into(), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::AlreadyMigrated {}); + + // owner is moved into cw_ownable. + let ownership = query_owner(&app, &staking); + assert_eq!( + ownership, + Ownership:: { + owner: Some(Addr::unchecked(OWNER)), + pending_owner: None, + pending_expiry: None + } + ); + + // config is loadable and has no manager, but is otherwise + // unchanged. + let config = query_config(&app, &staking); + assert_eq!( + config, + Config { + token_address: cw20_addr, + unstaking_duration: None, + } + ); +} diff --git a/contracts/voting/dao-voting-cw20-staked/.cargo/config b/contracts/voting/dao-voting-cw20-staked/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/voting/dao-voting-cw20-staked/Cargo.toml b/contracts/voting/dao-voting-cw20-staked/Cargo.toml new file mode 100644 index 000000000..7b2b0d6a3 --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "dao-voting-cw20-staked" +authors = ["Callum Anderson "] +description = "A DAO DAO voting module based on staked cw20 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 = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw20-stake = { workspace = true, features = ["library"] } +thiserror = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/contracts/voting/dao-voting-cw20-staked/README.md b/contracts/voting/dao-voting-cw20-staked/README.md new file mode 100644 index 000000000..78fb52602 --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/README.md @@ -0,0 +1,49 @@ +# CW20 Staked Balance Voting + +A voting power module which determines voting power based on the +staked token balance of specific addresses at given heights. + +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). +It also features the functionality to set an active threshold, this +threshold allows DAOs to be marked as inactive if it is not met. This +threshold can either be an absolute count of tokens staked or a +percentage of the token's total supply. + +## Endpoints + +### Execute + +`UpdateActiveThreshold` - Allows the user to update the active +threshold. + +### Query + +`TokenContract` - Provided via the `token_query` macro, simply returns +the underlying CW20 token's address. + +`StakingContract` - Returns the underlying staking contract used to +derive voting power at a given height. Should point to an instance of +`cw20-stake`. + +`VotingPowerAtHeight` - Given an address and an optional height, +return the voting power that address has at that height. If no height +is given it defaults to the current block height. In this case it is +the address' staked balance at that height. + +`TotalPowerAtHeight` - Given an optional height, determine the total +voting power available. If no height is given it defaults to the +current block height. In this case it is the total staked balance at +that height. + +`Info` - Uses the CW2 spec to return the contracts info. + +`Dao` - Returns the DAO that this voting module belongs to. + +`IsActive` - Returns true or false depending on if this DAO is active +and can make proposals. Uses the active threshold described above to +determine this. + +`ActiveThreshold` - Returns the details for the current active +threshold in place, if any. diff --git a/contracts/voting/dao-voting-cw20-staked/examples/schema.rs b/contracts/voting/dao-voting-cw20-staked/examples/schema.rs new file mode 100644 index 000000000..6099e7a9e --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_voting_cw20_staked::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} 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 new file mode 100644 index 000000000..d253baee1 --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/schema/dao-voting-cw20-staked.json @@ -0,0 +1,849 @@ +{ + "contract_name": "dao-voting-cw20-staked", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "token_info" + ], + "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" + } + ] + }, + "token_info": { + "$ref": "#/definitions/TokenInfo" + } + }, + "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 + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Cw20Coin": { + "type": "object", + "required": [ + "address", + "amount" + ], + "properties": { + "address": { + "type": "string" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "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 + } + ] + }, + "EmbeddedLogo": { + "description": "This is used to store the logo on the blockchain in an accepted format. Enforce maximum size of 5KB on all variants.", + "oneOf": [ + { + "description": "Store the Logo as an SVG file. The content must conform to the spec at https://en.wikipedia.org/wiki/Scalable_Vector_Graphics (The contract should do some light-weight sanity-check validation)", + "type": "object", + "required": [ + "svg" + ], + "properties": { + "svg": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + }, + { + "description": "Store the Logo as a PNG file. This will likely only support up to 64x64 or so within the 5KB limit.", + "type": "object", + "required": [ + "png" + ], + "properties": { + "png": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + } + ] + }, + "InstantiateMarketingInfo": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/Logo" + }, + { + "type": "null" + } + ] + }, + "marketing": { + "type": [ + "string", + "null" + ] + }, + "project": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "Logo": { + "description": "This is used for uploading logo data, or setting it in InstantiateData", + "oneOf": [ + { + "description": "A reference to an externally hosted logo. Must be a valid HTTP or HTTPS URL.", + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "Logo content stored on the blockchain. Enforce maximum size of 5KB on all variants", + "type": "object", + "required": [ + "embedded" + ], + "properties": { + "embedded": { + "$ref": "#/definitions/EmbeddedLogo" + } + }, + "additionalProperties": false + } + ] + }, + "StakingInfo": { + "description": "Information about the staking contract to be used with this voting module.", + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "staking_contract_address" + ], + "properties": { + "staking_contract_address": { + "description": "Address of an already instantiated staking contract.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "type": "object", + "required": [ + "staking_code_id" + ], + "properties": { + "staking_code_id": { + "description": "Code ID for staking contract to instantiate.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "unstaking_duration": { + "description": "See corresponding field in cw20-stake's instantiation. This will be used when instantiating the new staking contract.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "TokenInfo": { + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "address", + "staking_contract" + ], + "properties": { + "address": { + "description": "Address of an already instantiated cw20 token contract.", + "type": "string" + }, + "staking_contract": { + "description": "Information about the staking contract to use.", + "allOf": [ + { + "$ref": "#/definitions/StakingInfo" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "type": "object", + "required": [ + "code_id", + "decimals", + "initial_balances", + "label", + "name", + "staking_code_id", + "symbol" + ], + "properties": { + "code_id": { + "description": "Code ID for cw20 token contract.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "decimals": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "initial_balances": { + "type": "array", + "items": { + "$ref": "#/definitions/Cw20Coin" + } + }, + "initial_dao_balance": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + }, + "label": { + "description": "Label to use for instantiated cw20 contract.", + "type": "string" + }, + "marketing": { + "anyOf": [ + { + "$ref": "#/definitions/InstantiateMarketingInfo" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "staking_code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "symbol": { + "type": "string" + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "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": "Sets the active threshold to a new value. Only the instantiator this contract (a DAO most likely) may call this method.", + "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" + }, + "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": [ + { + "description": "Gets the address of the cw20-stake contract this voting module is wrapping.", + "type": "object", + "required": [ + "staking_contract" + ], + "properties": { + "staking_contract": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "active_threshold" + ], + "properties": { + "active_threshold": { + "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 + }, + { + "type": "object", + "required": [ + "token_contract" + ], + "properties": { + "token_contract": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "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" + } + } + }, + "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" + }, + "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" + }, + "staking_contract": { + "$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" + }, + "token_contract": { + "$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" + }, + "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-cw20-staked/src/contract.rs b/contracts/voting/dao-voting-cw20-staked/src/contract.rs new file mode 100644 index 000000000..4033d5247 --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/src/contract.rs @@ -0,0 +1,444 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Reply, Response, StdResult, + SubMsg, Uint128, Uint256, WasmMsg, +}; +use cw2::set_contract_version; +use cw20::{Cw20Coin, TokenInfoResponse}; +use cw_utils::parse_reply_instantiate_data; +use dao_interface::voting::IsActiveResponse; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; +use std::convert::TryInto; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, StakingInfo, TokenInfo}; +use crate::state::{ + ACTIVE_THRESHOLD, DAO, STAKING_CONTRACT, STAKING_CONTRACT_CODE_ID, + STAKING_CONTRACT_UNSTAKING_DURATION, TOKEN, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-cw20-staked"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const INSTANTIATE_TOKEN_REPLY_ID: u64 = 0; +const INSTANTIATE_STAKING_REPLY_ID: u64 = 1; + +// 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 { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + DAO.save(deps.storage, &info.sender)?; + + if let Some(active_threshold) = msg.active_threshold.as_ref() { + if let ActiveThreshold::Percentage { percent } = active_threshold { + if *percent > Decimal::percent(100) || *percent <= Decimal::percent(0) { + return Err(ContractError::InvalidActivePercentage {}); + } + } + ACTIVE_THRESHOLD.save(deps.storage, active_threshold)?; + } + + match msg.token_info { + TokenInfo::Existing { + address, + staking_contract, + } => { + let address = deps.api.addr_validate(&address)?; + TOKEN.save(deps.storage, &address)?; + if let Some(ActiveThreshold::AbsoluteCount { count }) = msg.active_threshold { + assert_valid_absolute_count_threshold(deps.as_ref(), &address, count)?; + } + + match staking_contract { + StakingInfo::Existing { + staking_contract_address, + } => { + let staking_contract_address = + deps.api.addr_validate(&staking_contract_address)?; + let resp: cw20_stake::state::Config = deps.querier.query_wasm_smart( + &staking_contract_address, + &cw20_stake::msg::QueryMsg::GetConfig {}, + )?; + + if address != resp.token_address { + return Err(ContractError::StakingContractMismatch {}); + } + + STAKING_CONTRACT.save(deps.storage, &staking_contract_address)?; + Ok(Response::default() + .add_attribute("action", "instantiate") + .add_attribute("token", "existing_token") + .add_attribute("token_address", address) + .add_attribute("staking_contract", staking_contract_address)) + } + StakingInfo::New { + staking_code_id, + unstaking_duration, + } => { + let msg = WasmMsg::Instantiate { + code_id: staking_code_id, + funds: vec![], + admin: Some(info.sender.to_string()), + label: env.contract.address.to_string(), + msg: to_binary(&cw20_stake::msg::InstantiateMsg { + owner: Some(info.sender.to_string()), + unstaking_duration, + token_address: address.to_string(), + })?, + }; + let msg = SubMsg::reply_on_success(msg, INSTANTIATE_STAKING_REPLY_ID); + Ok(Response::default() + .add_attribute("action", "instantiate") + .add_attribute("token", "existing_token") + .add_attribute("token_address", address) + .add_submessage(msg)) + } + } + } + TokenInfo::New { + code_id, + label, + name, + symbol, + decimals, + mut initial_balances, + initial_dao_balance, + marketing, + staking_code_id, + unstaking_duration, + } => { + let initial_supply = initial_balances + .iter() + .fold(Uint128::zero(), |p, n| p + n.amount); + // Cannot instantiate with no initial token owners because + // it would immediately lock the DAO. + if initial_supply.is_zero() { + return Err(ContractError::InitialBalancesError {}); + } + + // Add DAO initial balance to initial_balances vector if defined. + if let Some(initial_dao_balance) = initial_dao_balance { + if initial_dao_balance > Uint128::zero() { + initial_balances.push(Cw20Coin { + address: info.sender.to_string(), + amount: initial_dao_balance, + }); + } + } + + STAKING_CONTRACT_CODE_ID.save(deps.storage, &staking_code_id)?; + STAKING_CONTRACT_UNSTAKING_DURATION.save(deps.storage, &unstaking_duration)?; + + let msg = WasmMsg::Instantiate { + admin: Some(info.sender.to_string()), + code_id, + msg: to_binary(&cw20_base::msg::InstantiateMsg { + name, + symbol, + decimals, + initial_balances, + mint: Some(cw20::MinterResponse { + minter: info.sender.to_string(), + cap: None, + }), + marketing, + })?, + funds: vec![], + label, + }; + let msg = SubMsg::reply_on_success(msg, INSTANTIATE_TOKEN_REPLY_ID); + + Ok(Response::default() + .add_attribute("action", "instantiate") + .add_attribute("token", "new_token") + .add_submessage(msg)) + } + } +} + +pub fn assert_valid_absolute_count_threshold( + deps: Deps, + token_addr: &Addr, + count: Uint128, +) -> Result<(), ContractError> { + if count.is_zero() { + return Err(ContractError::ZeroActiveCount {}); + } + let token_info: cw20::TokenInfoResponse = deps + .querier + .query_wasm_smart(token_addr, &cw20_base::msg::QueryMsg::TokenInfo {})?; + if count > token_info.total_supply { + return Err(ContractError::InvalidAbsoluteCount {}); + } + Ok(()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::UpdateActiveThreshold { new_threshold } => { + execute_update_active_threshold(deps, env, info, new_threshold) + } + } +} + +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 {}); + } + + if let Some(active_threshold) = new_active_threshold { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + if percent > Decimal::percent(100) || percent.is_zero() { + return Err(ContractError::InvalidActivePercentage {}); + } + } + ActiveThreshold::AbsoluteCount { count } => { + let token = TOKEN.load(deps.storage)?; + assert_valid_absolute_count_threshold(deps.as_ref(), &token, count)?; + } + } + 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::TokenContract {} => query_token_contract(deps), + QueryMsg::StakingContract {} => query_staking_contract(deps), + QueryMsg::VotingPowerAtHeight { address, height } => { + query_voting_power_at_height(deps, env, address, height) + } + QueryMsg::TotalPowerAtHeight { height } => query_total_power_at_height(deps, env, height), + QueryMsg::Info {} => query_info(deps), + QueryMsg::Dao {} => query_dao(deps), + QueryMsg::IsActive {} => query_is_active(deps), + QueryMsg::ActiveThreshold {} => query_active_threshold(deps), + } +} + +pub fn query_token_contract(deps: Deps) -> StdResult { + let token = TOKEN.load(deps.storage)?; + to_binary(&token) +} + +pub fn query_staking_contract(deps: Deps) -> StdResult { + let staking_contract = STAKING_CONTRACT.load(deps.storage)?; + to_binary(&staking_contract) +} + +pub fn query_voting_power_at_height( + deps: Deps, + _env: Env, + address: String, + height: Option, +) -> StdResult { + let staking_contract = STAKING_CONTRACT.load(deps.storage)?; + let address = deps.api.addr_validate(&address)?; + let res: cw20_stake::msg::StakedBalanceAtHeightResponse = deps.querier.query_wasm_smart( + staking_contract, + &cw20_stake::msg::QueryMsg::StakedBalanceAtHeight { + address: address.to_string(), + height, + }, + )?; + to_binary(&dao_interface::voting::VotingPowerAtHeightResponse { + power: res.balance, + height: res.height, + }) +} + +pub fn query_total_power_at_height( + deps: Deps, + _env: Env, + height: Option, +) -> StdResult { + let staking_contract = STAKING_CONTRACT.load(deps.storage)?; + let res: cw20_stake::msg::TotalStakedAtHeightResponse = deps.querier.query_wasm_smart( + staking_contract, + &cw20_stake::msg::QueryMsg::TotalStakedAtHeight { height }, + )?; + to_binary(&dao_interface::voting::TotalPowerAtHeightResponse { + power: res.total, + height: res.height, + }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let dao = DAO.load(deps.storage)?; + to_binary(&dao) +} + +pub fn query_is_active(deps: Deps) -> StdResult { + let threshold = ACTIVE_THRESHOLD.may_load(deps.storage)?; + if let Some(threshold) = threshold { + let token_contract = TOKEN.load(deps.storage)?; + let staking_contract = STAKING_CONTRACT.load(deps.storage)?; + let actual_power: cw20_stake::msg::TotalStakedAtHeightResponse = + deps.querier.query_wasm_smart( + staking_contract, + &cw20_stake::msg::QueryMsg::TotalStakedAtHeight { height: None }, + )?; + match threshold { + ActiveThreshold::AbsoluteCount { count } => to_binary(&IsActiveResponse { + active: actual_power.total >= count, + }), + ActiveThreshold::Percentage { percent } => { + // 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^128] + // as it tracks the balances of a cw20 token which has + // a max supply of 2^128. + // + // with our precision factor being 10^9: + // + // total_power <= 2^128 * 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_potential_power: TokenInfoResponse = deps + .querier + .query_wasm_smart(token_contract, &cw20_base::msg::QueryMsg::TokenInfo {})?; + let total_power = total_potential_power + .total_supply + .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_power.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(); + to_binary(&IsActiveResponse { + active: actual_power.total >= count, + }) + } + } + } else { + to_binary(&IsActiveResponse { active: true }) + } +} + +pub fn query_active_threshold(deps: Deps) -> StdResult { + to_binary(&ActiveThresholdResponse { + active_threshold: ACTIVE_THRESHOLD.may_load(deps.storage)?, + }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> Result { + match msg.id { + INSTANTIATE_TOKEN_REPLY_ID => { + let res = parse_reply_instantiate_data(msg); + match res { + Ok(res) => { + let token = TOKEN.may_load(deps.storage)?; + if token.is_some() { + // There is no known way this error could ever + // be triggered, we're just paranoid. + return Err(ContractError::DuplicateToken {}); + } + let token = deps.api.addr_validate(&res.contract_address)?; + TOKEN.save(deps.storage, &token)?; + + let active_threshold = ACTIVE_THRESHOLD.may_load(deps.storage)?; + if let Some(ActiveThreshold::AbsoluteCount { count }) = active_threshold { + assert_valid_absolute_count_threshold(deps.as_ref(), &token, count)?; + } + + let staking_contract_code_id = STAKING_CONTRACT_CODE_ID.load(deps.storage)?; + let unstaking_duration = + STAKING_CONTRACT_UNSTAKING_DURATION.load(deps.storage)?; + let dao = DAO.load(deps.storage)?; + let msg = WasmMsg::Instantiate { + code_id: staking_contract_code_id, + funds: vec![], + admin: Some(dao.to_string()), + label: env.contract.address.to_string(), + msg: to_binary(&cw20_stake::msg::InstantiateMsg { + owner: Some(dao.to_string()), + unstaking_duration, + token_address: token.to_string(), + })?, + }; + let msg = SubMsg::reply_on_success(msg, INSTANTIATE_STAKING_REPLY_ID); + Ok(Response::default() + .add_attribute("token_address", token) + .add_submessage(msg)) + } + Err(_) => Err(ContractError::TokenInstantiateError {}), + } + } + INSTANTIATE_STAKING_REPLY_ID => { + let res = parse_reply_instantiate_data(msg); + match res { + Ok(res) => { + let staking_contract_addr = deps.api.addr_validate(&res.contract_address)?; + + let staking = STAKING_CONTRACT.may_load(deps.storage)?; + if staking.is_some() { + return Err(ContractError::DuplicateStakingContract {}); + } + + STAKING_CONTRACT.save(deps.storage, &staking_contract_addr)?; + + Ok(Response::new().add_attribute("staking_contract", staking_contract_addr)) + } + Err(_) => Err(ContractError::StakingInstantiateError {}), + } + } + _ => Err(ContractError::UnknownReplyId { id: msg.id }), + } +} diff --git a/contracts/voting/dao-voting-cw20-staked/src/error.rs b/contracts/voting/dao-voting-cw20-staked/src/error.rs new file mode 100644 index 000000000..be2ae0a36 --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/src/error.rs @@ -0,0 +1,41 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Initial governance token balances must not be empty")] + InitialBalancesError {}, + + #[error("Can not change the contract's token after it has been set")] + DuplicateToken {}, + + #[error("Error instantiating token")] + TokenInstantiateError {}, + + #[error("Error instantiating staking contract")] + StakingInstantiateError {}, + + #[error("Got a submessage reply with unknown id: {id}")] + UnknownReplyId { id: u64 }, + + #[error("Staking contract token address does not match provided token address")] + StakingContractMismatch {}, + + #[error("Can not change the contract's staking contract after it has been set")] + DuplicateStakingContract {}, + + #[error("Active threshold percentage must be greater than 0 and less than 1")] + InvalidActivePercentage {}, + + #[error("Active threshold count must be greater than zero")] + ZeroActiveCount {}, + + #[error("Absolute count threshold cannot be greater than the total token supply")] + InvalidAbsoluteCount {}, +} diff --git a/contracts/voting/dao-voting-cw20-staked/src/lib.rs b/contracts/voting/dao-voting-cw20-staked/src/lib.rs new file mode 100644 index 000000000..d1800adbc --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/voting/dao-voting-cw20-staked/src/msg.rs b/contracts/voting/dao-voting-cw20-staked/src/msg.rs new file mode 100644 index 000000000..33753fcae --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/src/msg.rs @@ -0,0 +1,88 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; +use cw20::Cw20Coin; +use cw20_base::msg::InstantiateMarketingInfo; +use cw_utils::Duration; + +use dao_dao_macros::{active_query, token_query, voting_module_query}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +/// Information about the staking contract to be used with this voting +/// module. +#[cw_serde] +pub enum StakingInfo { + Existing { + /// Address of an already instantiated staking contract. + staking_contract_address: String, + }, + New { + /// Code ID for staking contract to instantiate. + staking_code_id: u64, + /// See corresponding field in cw20-stake's + /// instantiation. This will be used when instantiating the + /// new staking contract. + unstaking_duration: Option, + }, +} + +#[cw_serde] +#[allow(clippy::large_enum_variant)] +pub enum TokenInfo { + Existing { + /// Address of an already instantiated cw20 token contract. + address: String, + /// Information about the staking contract to use. + staking_contract: StakingInfo, + }, + New { + /// Code ID for cw20 token contract. + code_id: u64, + /// Label to use for instantiated cw20 contract. + label: String, + + name: String, + symbol: String, + decimals: u8, + initial_balances: Vec, + marketing: Option, + + staking_code_id: u64, + unstaking_duration: Option, + initial_dao_balance: Option, + }, +} + +#[cw_serde] +pub struct InstantiateMsg { + pub token_info: TokenInfo, + /// 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 { + /// Sets the active threshold to a new value. Only the + /// instantiator this contract (a DAO most likely) may call this + /// method. + UpdateActiveThreshold { + new_threshold: Option, + }, +} + +#[voting_module_query] +#[token_query] +#[active_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Gets the address of the cw20-stake contract this voting module + /// is wrapping. + #[returns(cosmwasm_std::Addr)] + StakingContract {}, + #[returns(ActiveThresholdResponse)] + ActiveThreshold {}, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/voting/dao-voting-cw20-staked/src/state.rs b/contracts/voting/dao-voting-cw20-staked/src/state.rs new file mode 100644 index 000000000..d4e61d397 --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/src/state.rs @@ -0,0 +1,12 @@ +use cosmwasm_std::Addr; +use cw_storage_plus::Item; +use cw_utils::Duration; +use dao_voting::threshold::ActiveThreshold; + +pub const ACTIVE_THRESHOLD: Item = Item::new("active_threshold"); +pub const TOKEN: Item = Item::new("token"); +pub const DAO: Item = Item::new("dao"); +pub const STAKING_CONTRACT: Item = Item::new("staking_contract"); +pub const STAKING_CONTRACT_UNSTAKING_DURATION: Item> = + Item::new("staking_contract_unstaking_duration"); +pub const STAKING_CONTRACT_CODE_ID: Item = Item::new("staking_contract_code_id"); diff --git a/contracts/voting/dao-voting-cw20-staked/src/tests.rs b/contracts/voting/dao-voting-cw20-staked/src/tests.rs new file mode 100644 index 000000000..b6d664604 --- /dev/null +++ b/contracts/voting/dao-voting-cw20-staked/src/tests.rs @@ -0,0 +1,1399 @@ +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env}, + to_binary, Addr, CosmosMsg, Decimal, Empty, Uint128, WasmMsg, +}; +use cw2::ContractVersion; +use cw20::{BalanceResponse, Cw20Coin, MinterResponse, TokenInfoResponse}; +use cw_multi_test::{next_block, App, Contract, ContractWrapper, Executor}; +use dao_interface::voting::{InfoResponse, IsActiveResponse, VotingPowerAtHeightResponse}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +use crate::{ + contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}, + msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, StakingInfo}, +}; + +const DAO_ADDR: &str = "dao"; +const CREATOR_ADDR: &str = "creator"; + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn staking_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_stake::contract::execute, + cw20_stake::contract::instantiate, + cw20_stake::contract::query, + ); + Box::new(contract) +} + +fn staked_balance_voting_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +fn instantiate_voting(app: &mut App, voting_id: u64, msg: InstantiateMsg) -> Addr { + app.instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "voting module", + None, + ) + .unwrap() +} + +fn stake_tokens(app: &mut App, staking_addr: Addr, cw20_addr: Addr, sender: &str, amount: u128) { + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::new(amount), + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(sender), cw20_addr, &msg, &[]) + .unwrap(); +} + +#[test] +#[should_panic(expected = "Initial governance token balances must not be empty")] +fn test_instantiate_zero_supply() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::zero(), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::zero()), + }, + active_threshold: None, + }, + ); +} + +#[test] +#[should_panic(expected = "Initial governance token balances must not be empty")] +fn test_instantiate_no_balances() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::zero()), + }, + active_threshold: None, + }, + ); +} + +#[test] +#[should_panic(expected = "Active threshold count must be greater than zero")] +fn test_instantiate_zero_active_threshold_count() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::one(), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::zero()), + }, + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(0), + }), + }, + ); +} + +#[test] +fn test_contract_info() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::zero()), + }, + active_threshold: None, + }, + ); + + let info: InfoResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::Info {}) + .unwrap(); + assert_eq!( + info, + InfoResponse { + info: ContractVersion { + contract: "crates.io:dao-voting-cw20-staked".to_string(), + version: env!("CARGO_PKG_VERSION").to_string() + } + } + ); + + let dao: Addr = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::Dao {}) + .unwrap(); + assert_eq!(dao, Addr::unchecked(DAO_ADDR)); +} + +#[test] +fn test_new_cw20() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::from(10u64)), + }, + active_threshold: None, + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + let staking_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::StakingContract {}) + .unwrap(); + + let token_info: TokenInfoResponse = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::TokenInfo {}) + .unwrap(); + assert_eq!( + token_info, + TokenInfoResponse { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + total_supply: Uint128::from(12u64) + } + ); + + let minter_info: Option = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::Minter {}) + .unwrap(); + assert_eq!( + minter_info, + Some(MinterResponse { + minter: DAO_ADDR.to_string(), + cap: None, + }) + ); + + // Expect DAO (sender address) to have initial balance. + let token_info: BalanceResponse = app + .wrap() + .query_wasm_smart( + token_addr.clone(), + &cw20::Cw20QueryMsg::Balance { + address: DAO_ADDR.to_string(), + }, + ) + .unwrap(); + assert_eq!( + token_info, + BalanceResponse { + balance: Uint128::from(10u64) + } + ); + + // Expect 0 as they have not staked + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::zero(), + height: app.block_info().height, + } + ); + + // Expect 0 as DAO has not staked + let dao_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: DAO_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + dao_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::zero(), + height: app.block_info().height, + } + ); + + // Stake 1 token as creator + stake_tokens(&mut app, staking_addr, token_addr, CREATOR_ADDR, 1); + app.update_block(next_block); + + // Expect 1 as creator has now staked 1 + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height, + } + ); + + // Expect 1 as only one token staked to make up whole voting power + let total_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::TotalPowerAtHeight { height: None }) + .unwrap(); + + assert_eq!( + total_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height, + } + ) +} + +#[test] +fn test_existing_cw20_new_staking() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_id = app.store_code(staking_contract()); + + let token_addr = app + .instantiate_contract( + cw20_id, + Addr::unchecked(CREATOR_ADDR), + &cw20_base::msg::InstantiateMsg { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + mint: None, + marketing: None, + }, + &[], + "voting token", + None, + ) + .unwrap(); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::Existing { + address: token_addr.to_string(), + staking_contract: StakingInfo::New { + staking_code_id: staking_id, + unstaking_duration: None, + }, + }, + active_threshold: None, + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + let staking_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::StakingContract {}) + .unwrap(); + + let token_info: TokenInfoResponse = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::TokenInfo {}) + .unwrap(); + assert_eq!( + token_info, + TokenInfoResponse { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 3, + total_supply: Uint128::from(2u64) + } + ); + + let minter_info: Option = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::Minter {}) + .unwrap(); + assert!(minter_info.is_none()); + + // Expect 0 as creator has not staked + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::zero(), + height: app.block_info().height, + } + ); + + // Expect 0 as DAO has not staked + let dao_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: DAO_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + dao_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::zero(), + height: app.block_info().height, + } + ); + + // Stake 1 token as creator + stake_tokens(&mut app, staking_addr, token_addr, CREATOR_ADDR, 1); + app.update_block(next_block); + + // Expect 1 as creator has now staked 1 + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height, + } + ); + + // Expect 1 as only one token staked to make up whole voting power + let total_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::TotalPowerAtHeight { height: None }) + .unwrap(); + + assert_eq!( + total_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height, + } + ) +} + +#[test] +fn test_existing_cw20_existing_staking() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_id = app.store_code(staking_contract()); + + let token_addr = app + .instantiate_contract( + cw20_id, + Addr::unchecked(CREATOR_ADDR), + &cw20_base::msg::InstantiateMsg { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + mint: None, + marketing: None, + }, + &[], + "voting token", + None, + ) + .unwrap(); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::Existing { + address: token_addr.to_string(), + staking_contract: StakingInfo::New { + staking_code_id: staking_id, + unstaking_duration: None, + }, + }, + active_threshold: None, + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + // We'll use this for our valid existing contract + let staking_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::StakingContract {}) + .unwrap(); + + let token_info: TokenInfoResponse = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::TokenInfo {}) + .unwrap(); + assert_eq!( + token_info, + TokenInfoResponse { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 3, + total_supply: Uint128::from(2u64) + } + ); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::Existing { + address: token_addr.to_string(), + staking_contract: StakingInfo::Existing { + staking_contract_address: staking_addr.to_string(), + }, + }, + active_threshold: None, + }, + ); + + // Expect 0 as creator has not staked + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::zero(), + height: app.block_info().height, + } + ); + + // Expect 0 as DAO has not staked + let dao_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: DAO_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + dao_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::zero(), + height: app.block_info().height, + } + ); + + // Stake 1 token as creator + stake_tokens(&mut app, staking_addr.clone(), token_addr, CREATOR_ADDR, 1); + app.update_block(next_block); + + // Expect 1 as creator has now staked 1 + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height, + } + ); + + // Expect 1 as only one token staked to make up whole voting power + let total_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::TotalPowerAtHeight { height: None }) + .unwrap(); + + assert_eq!( + total_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height, + } + ); + + // Now lets test the error case where we use an invalid staking contract + let different_token = app + .instantiate_contract( + cw20_id, + Addr::unchecked(CREATOR_ADDR), + &cw20_base::msg::InstantiateMsg { + name: "DAO DAO MISMATCH".to_string(), + symbol: "DAOM".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + mint: None, + marketing: None, + }, + &[], + "voting token", + None, + ) + .unwrap(); + + // Expect error as the token address does not match the staking address token address + app.instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &InstantiateMsg { + token_info: crate::msg::TokenInfo::Existing { + address: different_token.to_string(), + staking_contract: StakingInfo::Existing { + staking_contract_address: staking_addr.to_string(), + }, + }, + active_threshold: None, + }, + &[], + "voting module", + None, + ) + .unwrap_err(); +} + +#[test] +fn test_different_heights() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_id = app.store_code(staking_contract()); + + let token_addr = app + .instantiate_contract( + cw20_id, + Addr::unchecked(CREATOR_ADDR), + &cw20_base::msg::InstantiateMsg { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + mint: None, + marketing: None, + }, + &[], + "voting token", + None, + ) + .unwrap(); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::Existing { + address: token_addr.to_string(), + staking_contract: StakingInfo::New { + staking_code_id: staking_id, + unstaking_duration: None, + }, + }, + active_threshold: None, + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + let staking_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::StakingContract {}) + .unwrap(); + + // Expect 0 as creator has not staked + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::zero(), + height: app.block_info().height, + } + ); + + // Stake 1 token as creator + stake_tokens( + &mut app, + staking_addr.clone(), + token_addr.clone(), + CREATOR_ADDR, + 1, + ); + app.update_block(next_block); + + // Expect 1 as creator has now staked 1 + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height, + } + ); + + // Expect 1 as only one token staked to make up whole voting power + let total_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { height: None }, + ) + .unwrap(); + + assert_eq!( + total_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height, + } + ); + + // Stake another 1 token as creator + stake_tokens(&mut app, staking_addr, token_addr, CREATOR_ADDR, 1); + app.update_block(next_block); + + // Expect 2 as creator has now staked 2 + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(2u128), + height: app.block_info().height, + } + ); + + // Expect 2 as we have now staked 2 + let total_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { height: None }, + ) + .unwrap(); + + assert_eq!( + total_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(2u128), + height: app.block_info().height, + } + ); + + // Check we can query history + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: Some(app.block_info().height - 1), + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height - 1, + } + ); + + // Expect 1 at the old height prior to second stake + let total_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr, + &QueryMsg::TotalPowerAtHeight { + height: Some(app.block_info().height - 1), + }, + ) + .unwrap(); + + assert_eq!( + total_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::new(1u128), + height: app.block_info().height - 1, + } + ); +} + +#[test] +fn test_active_threshold_absolute_count() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(200u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::from(100u64)), + }, + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + let staking_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::StakingContract {}) + .unwrap(); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 100 token as creator + stake_tokens(&mut app, staking_addr, token_addr, CREATOR_ADDR, 100); + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_percent() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(200u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::from(100u64)), + }, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(20), + }), + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + let staking_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::StakingContract {}) + .unwrap(); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 60 token as creator, now active + stake_tokens(&mut app, staking_addr, token_addr, CREATOR_ADDR, 60); + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_percent_rounds_up() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(5u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: None, + }, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(50), + }), + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + let staking_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::StakingContract {}) + .unwrap(); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 2 token as creator, should not be active. + stake_tokens( + &mut app, + staking_addr.clone(), + token_addr.clone(), + CREATOR_ADDR, + 2, + ); + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 1 more token as creator, should now be active. + stake_tokens(&mut app, staking_addr, token_addr, CREATOR_ADDR, 1); + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_none() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(200u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::from(100u64)), + }, + active_threshold: None, + }, + ); + + // Active as no threshold + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_update_active_threshold() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(200u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::from(100u64)), + }, + active_threshold: None, + }, + ); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!(resp.active_threshold, None); + + let msg = ExecuteMsg::UpdateActiveThreshold { + new_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + }; + + // Expect failure as sender is not the DAO + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + voting_addr.clone(), + &msg, + &[], + ) + .unwrap_err(); + + // Expect success as sender is the DAO + app.execute_contract(Addr::unchecked(DAO_ADDR), voting_addr.clone(), &msg, &[]) + .unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!( + resp.active_threshold, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100) + }) + ); +} + +#[test] +#[should_panic(expected = "Active threshold percentage must be greater than 0 and less than 1")] +fn test_active_threshold_percentage_gt_100() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(200u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::from(100u64)), + }, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(120), + }), + }, + ); +} + +#[test] +#[should_panic(expected = "Active threshold percentage must be greater than 0 and less than 1")] +fn test_active_threshold_percentage_lte_0() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(200u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::from(100u64)), + }, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(0), + }), + }, + ); +} + +#[test] +#[should_panic(expected = "Absolute count threshold cannot be greater than the total token supply")] +fn test_active_threshold_absolute_count_invalid() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(200u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::from(100u64)), + }, + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(10000), + }), + }, + ); +} + +#[test] +fn test_migrate() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(staked_balance_voting_contract()); + let staking_contract_id = app.store_code(staking_contract()); + + let voting_addr = app + .instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + unstaking_duration: None, + staking_code_id: staking_contract_id, + initial_dao_balance: Some(Uint128::zero()), + }, + active_threshold: None, + }, + &[], + "voting module", + Some(DAO_ADDR.to_string()), + ) + .unwrap(); + + let info: InfoResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::Info {}) + .unwrap(); + + let dao: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::Dao {}) + .unwrap(); + + app.execute( + dao, + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: voting_addr.to_string(), + new_code_id: voting_id, + msg: to_binary(&MigrateMsg {}).unwrap(), + }), + ) + .unwrap(); + + let new_info: InfoResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::Info {}) + .unwrap(); + + assert_eq!(info, new_info); +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").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); +} diff --git a/contracts/voting/dao-voting-cw4/.cargo/config b/contracts/voting/dao-voting-cw4/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/voting/dao-voting-cw4/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/voting/dao-voting-cw4/Cargo.toml b/contracts/voting/dao-voting-cw4/Cargo.toml new file mode 100644 index 000000000..dbe6c6718 --- /dev/null +++ b/contracts/voting/dao-voting-cw4/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "dao-voting-cw4" +authors = ["Callum Anderson "] +description = "A DAO DAO voting module based on cw4 membership." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw-utils = { workspace = true } +cosmwasm-schema = { workspace = true } +thiserror = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } +cw4 = { workspace = true } +cw4-group = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/contracts/voting/dao-voting-cw4/README.md b/contracts/voting/dao-voting-cw4/README.md new file mode 100644 index 000000000..14793999c --- /dev/null +++ b/contracts/voting/dao-voting-cw4/README.md @@ -0,0 +1,24 @@ +# CW4 Group Voting + +A simple voting power module which determines voting power based on +the weight of a user in a cw4-group contract. This allocates voting +power in the same way that one would expect a multisig to. + +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). +For more information about how these modules fit together see +[this](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design) +wiki page. + +## Receiving updates + +This contract does not make subqueries to the cw4-group contract to +get an addresses voting power. Instead, it listens for +`MemberChangedHook` messages from said contract and caches voting +power locally. + +As the DAO is the admin of the underlying cw4-group contract it is +important that the DAO does not remove this contract from that +contract's list of hook receivers. Doing so will cause this contract +to stop receiving voting power updates. diff --git a/contracts/voting/dao-voting-cw4/examples/schema.rs b/contracts/voting/dao-voting-cw4/examples/schema.rs new file mode 100644 index 000000000..09cf2417d --- /dev/null +++ b/contracts/voting/dao-voting-cw4/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_voting_cw4::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/voting/dao-voting-cw4/schema/dao-voting-cw4.json b/contracts/voting/dao-voting-cw4/schema/dao-voting-cw4.json new file mode 100644 index 000000000..377c183ad --- /dev/null +++ b/contracts/voting/dao-voting-cw4/schema/dao-voting-cw4.json @@ -0,0 +1,309 @@ +{ + "contract_name": "dao-voting-cw4", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "group_contract" + ], + "properties": { + "group_contract": { + "$ref": "#/definitions/GroupContract" + } + }, + "additionalProperties": false, + "definitions": { + "GroupContract": { + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "type": "object", + "required": [ + "cw4_group_code_id", + "initial_members" + ], + "properties": { + "cw4_group_code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "initial_members": { + "type": "array", + "items": { + "$ref": "#/definitions/Member" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Member": { + "description": "A group member has a weight associated with them. This may all be equal, or may have meaning in the app that makes use of the group (eg. voting power)", + "type": "object", + "required": [ + "addr", + "weight" + ], + "properties": { + "addr": { + "type": "string" + }, + "weight": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "type": "string", + "enum": [] + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "group_contract" + ], + "properties": { + "group_contract": { + "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": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "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" + }, + "group_contract": { + "$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" + }, + "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 + } + } + }, + "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-cw4/src/contract.rs b/contracts/voting/dao-voting-cw4/src/contract.rs new file mode 100644 index 000000000..efa311007 --- /dev/null +++ b/contracts/voting/dao-voting-cw4/src/contract.rs @@ -0,0 +1,207 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Reply, Response, StdResult, SubMsg, + Uint128, WasmMsg, +}; +use cw2::set_contract_version; +use cw4::{MemberListResponse, MemberResponse, TotalWeightResponse}; +use cw_utils::parse_reply_instantiate_data; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, GroupContract, InstantiateMsg, MigrateMsg, QueryMsg}; +use crate::state::{DAO, GROUP_CONTRACT}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-cw4"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const INSTANTIATE_GROUP_REPLY_ID: u64 = 0; + +#[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)?; + + DAO.save(deps.storage, &info.sender)?; + + match msg.group_contract { + GroupContract::New { + cw4_group_code_id, + initial_members, + } => { + if initial_members.is_empty() { + return Err(ContractError::NoMembers {}); + } + let original_len = initial_members.len(); + let mut initial_members = initial_members; + initial_members.sort_by(|a, b| a.addr.cmp(&b.addr)); + initial_members.dedup(); + let new_len = initial_members.len(); + + if original_len != new_len { + return Err(ContractError::DuplicateMembers {}); + } + + let mut total_weight = Uint128::zero(); + for member in initial_members.iter() { + deps.api.addr_validate(&member.addr)?; + if member.weight > 0 { + // This works because query_voting_power_at_height will return 0 on address missing + // from storage, so no need to store anything. + let weight = Uint128::from(member.weight); + total_weight += weight; + } + } + + if total_weight.is_zero() { + return Err(ContractError::ZeroTotalWeight {}); + } + + // We need to set ourself as the CW4 admin it is then transferred to the DAO in the reply + let msg = WasmMsg::Instantiate { + admin: Some(info.sender.to_string()), + code_id: cw4_group_code_id, + msg: to_binary(&cw4_group::msg::InstantiateMsg { + admin: Some(env.contract.address.to_string()), + members: initial_members, + })?, + funds: vec![], + label: env.contract.address.to_string(), + }; + + let msg = SubMsg::reply_on_success(msg, INSTANTIATE_GROUP_REPLY_ID); + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_submessage(msg)) + } + GroupContract::Existing { address } => { + let group_contract = deps.api.addr_validate(&address)?; + + // Validate valid group contract that has at least one member. + let res: MemberListResponse = deps.querier.query_wasm_smart( + group_contract.clone(), + &cw4_group::msg::QueryMsg::ListMembers { + start_after: None, + limit: Some(1), + }, + )?; + + if res.members.is_empty() { + return Err(ContractError::NoMembers {}); + } + + GROUP_CONTRACT.save(deps.storage, &group_contract)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("group_contract", "address")) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: ExecuteMsg, +) -> Result { + Err(ContractError::NoExecute {}) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::VotingPowerAtHeight { address, height } => { + query_voting_power_at_height(deps, env, address, height) + } + QueryMsg::TotalPowerAtHeight { height } => query_total_power_at_height(deps, env, height), + QueryMsg::Info {} => query_info(deps), + QueryMsg::GroupContract {} => to_binary(&GROUP_CONTRACT.load(deps.storage)?), + QueryMsg::Dao {} => to_binary(&DAO.load(deps.storage)?), + } +} + +pub fn query_voting_power_at_height( + deps: Deps, + env: Env, + address: String, + height: Option, +) -> StdResult { + let addr = deps.api.addr_validate(&address)?.to_string(); + let group_contract = GROUP_CONTRACT.load(deps.storage)?; + let res: MemberResponse = deps.querier.query_wasm_smart( + group_contract, + &cw4_group::msg::QueryMsg::Member { + addr, + at_height: height, + }, + )?; + + to_binary(&dao_interface::voting::VotingPowerAtHeightResponse { + power: res.weight.unwrap_or(0).into(), + height: height.unwrap_or(env.block.height), + }) +} + +pub fn query_total_power_at_height(deps: Deps, env: Env, height: Option) -> StdResult { + let group_contract = GROUP_CONTRACT.load(deps.storage)?; + let res: TotalWeightResponse = deps.querier.query_wasm_smart( + group_contract, + &cw4_group::msg::QueryMsg::TotalWeight { at_height: height }, + )?; + to_binary(&dao_interface::voting::TotalPowerAtHeightResponse { + power: res.weight.into(), + height: height.unwrap_or(env.block.height), + }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + INSTANTIATE_GROUP_REPLY_ID => { + let res = parse_reply_instantiate_data(msg); + match res { + Ok(res) => { + let group_contract = GROUP_CONTRACT.may_load(deps.storage)?; + if group_contract.is_some() { + return Err(ContractError::DuplicateGroupContract {}); + } + let group_contract = deps.api.addr_validate(&res.contract_address)?; + let dao = DAO.load(deps.storage)?; + GROUP_CONTRACT.save(deps.storage, &group_contract)?; + // Transfer admin status to the DAO + let msg1 = WasmMsg::Execute { + contract_addr: group_contract.to_string(), + msg: to_binary(&cw4_group::msg::ExecuteMsg::UpdateAdmin { + admin: Some(dao.to_string()), + })?, + funds: vec![], + }; + Ok(Response::default() + .add_attribute("group_contract_address", group_contract) + .add_message(msg1)) + } + Err(_) => Err(ContractError::GroupContractInstantiateError {}), + } + } + _ => Err(ContractError::UnknownReplyId { id: msg.id }), + } +} diff --git a/contracts/voting/dao-voting-cw4/src/error.rs b/contracts/voting/dao-voting-cw4/src/error.rs new file mode 100644 index 000000000..a0ce03e9c --- /dev/null +++ b/contracts/voting/dao-voting-cw4/src/error.rs @@ -0,0 +1,32 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Can not change the contract's token after it has been set")] + DuplicateGroupContract {}, + + #[error("Cannot instantiate a group contract with duplicate initial members")] + DuplicateMembers {}, + + #[error("Error occured whilst instantiating group contract")] + GroupContractInstantiateError {}, + + #[error("Contract only supports queries")] + NoExecute {}, + + #[error("Cannot instantiate or use a group contract with no initial members")] + NoMembers {}, + + #[error("Got a submessage reply with unknown id: {id}")] + UnknownReplyId { id: u64 }, + + #[error("Total weight of the CW4 contract cannot be zero")] + ZeroTotalWeight {}, +} diff --git a/contracts/voting/dao-voting-cw4/src/lib.rs b/contracts/voting/dao-voting-cw4/src/lib.rs new file mode 100644 index 000000000..d1800adbc --- /dev/null +++ b/contracts/voting/dao-voting-cw4/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/voting/dao-voting-cw4/src/msg.rs b/contracts/voting/dao-voting-cw4/src/msg.rs new file mode 100644 index 000000000..24bd0eebc --- /dev/null +++ b/contracts/voting/dao-voting-cw4/src/msg.rs @@ -0,0 +1,32 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use dao_dao_macros::voting_module_query; + +#[cw_serde] +pub enum GroupContract { + Existing { + address: String, + }, + New { + cw4_group_code_id: u64, + initial_members: Vec, + }, +} + +#[cw_serde] +pub struct InstantiateMsg { + pub group_contract: GroupContract, +} + +#[cw_serde] +pub enum ExecuteMsg {} + +#[voting_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(cosmwasm_std::Addr)] + GroupContract {}, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/voting/dao-voting-cw4/src/state.rs b/contracts/voting/dao-voting-cw4/src/state.rs new file mode 100644 index 000000000..bdc0d8004 --- /dev/null +++ b/contracts/voting/dao-voting-cw4/src/state.rs @@ -0,0 +1,5 @@ +use cosmwasm_std::Addr; +use cw_storage_plus::Item; + +pub const GROUP_CONTRACT: Item = Item::new("group_contract"); +pub const DAO: Item = Item::new("dao_address"); diff --git a/contracts/voting/dao-voting-cw4/src/tests.rs b/contracts/voting/dao-voting-cw4/src/tests.rs new file mode 100644 index 000000000..386d8f0e6 --- /dev/null +++ b/contracts/voting/dao-voting-cw4/src/tests.rs @@ -0,0 +1,743 @@ +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env}, + to_binary, Addr, CosmosMsg, Empty, Uint128, WasmMsg, +}; +use cw2::ContractVersion; +use cw_multi_test::{next_block, App, Contract, ContractWrapper, Executor}; +use dao_interface::voting::{ + InfoResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; + +use crate::{ + contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}, + msg::{GroupContract, InstantiateMsg, MigrateMsg, QueryMsg}, + ContractError, +}; + +const DAO_ADDR: &str = "dao"; +const ADDR1: &str = "addr1"; +const ADDR2: &str = "addr2"; +const ADDR3: &str = "addr3"; +const ADDR4: &str = "addr4"; + +fn cw4_contract() -> Box> { + let contract = ContractWrapper::new( + cw4_group::contract::execute, + cw4_group::contract::instantiate, + cw4_group::contract::query, + ); + Box::new(contract) +} + +fn voting_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +fn instantiate_voting(app: &mut App, voting_id: u64, msg: InstantiateMsg) -> Addr { + app.instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "voting module", + None, + ) + .unwrap() +} + +fn setup_test_case(app: &mut App) -> Addr { + let cw4_id = app.store_code(cw4_contract()); + let voting_id = app.store_code(voting_contract()); + + let members = vec![ + cw4::Member { + addr: ADDR1.to_string(), + weight: 1, + }, + cw4::Member { + addr: ADDR2.to_string(), + weight: 1, + }, + cw4::Member { + addr: ADDR3.to_string(), + weight: 1, + }, + cw4::Member { + addr: ADDR4.to_string(), + weight: 0, + }, + ]; + instantiate_voting( + app, + voting_id, + InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members: members, + }, + }, + ) +} + +#[test] +fn test_instantiate() { + let mut app = App::default(); + // Valid instantiate no panics + let _voting_addr = setup_test_case(&mut app); + + // Instantiate with no members, error + let voting_id = app.store_code(voting_contract()); + let cw4_id = app.store_code(cw4_contract()); + let msg = InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members: [].into(), + }, + }; + let _err = app + .instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "voting module", + None, + ) + .unwrap_err(); + + // Instantiate with members but no weight + let msg = InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members: vec![ + cw4::Member { + addr: ADDR1.to_string(), + weight: 0, + }, + cw4::Member { + addr: ADDR2.to_string(), + weight: 0, + }, + cw4::Member { + addr: ADDR3.to_string(), + weight: 0, + }, + ], + }, + }; + let _err = app + .instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "voting module", + None, + ) + .unwrap_err(); +} + +#[test] +pub fn test_instantiate_existing_contract() { + let mut app = App::default(); + + let voting_id = app.store_code(voting_contract()); + let cw4_id = app.store_code(cw4_contract()); + + // Fail with no members. + let cw4_addr = app + .instantiate_contract( + cw4_id, + Addr::unchecked(DAO_ADDR), + &cw4_group::msg::InstantiateMsg { + admin: Some(DAO_ADDR.to_string()), + members: vec![], + }, + &[], + "cw4 group", + None, + ) + .unwrap(); + + let err: ContractError = app + .instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &InstantiateMsg { + group_contract: GroupContract::Existing { + address: cw4_addr.to_string(), + }, + }, + &[], + "voting module", + None, + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::NoMembers {}); + + let cw4_addr = app + .instantiate_contract( + cw4_id, + Addr::unchecked(DAO_ADDR), + &cw4_group::msg::InstantiateMsg { + admin: Some(DAO_ADDR.to_string()), + members: vec![cw4::Member { + addr: ADDR1.to_string(), + weight: 1, + }], + }, + &[], + "cw4 group", + None, + ) + .unwrap(); + + // Instantiate with existing contract + let msg = InstantiateMsg { + group_contract: GroupContract::Existing { + address: cw4_addr.to_string(), + }, + }; + let _err = app + .instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "voting module", + None, + ) + .unwrap(); + + // Update ADDR1's weight to 2 + let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![], + add: vec![cw4::Member { + addr: ADDR1.to_string(), + weight: 2, + }], + }; + + app.execute_contract(Addr::unchecked(DAO_ADDR), cw4_addr.clone(), &msg, &[]) + .unwrap(); + + // Same should be true about the groups contract. + let cw4_power: cw4::MemberResponse = app + .wrap() + .query_wasm_smart( + cw4_addr, + &cw4::Cw4QueryMsg::Member { + addr: ADDR1.to_string(), + at_height: None, + }, + ) + .unwrap(); + assert_eq!(cw4_power.weight.unwrap(), 2); +} + +#[test] +fn test_contract_info() { + let mut app = App::default(); + let voting_addr = setup_test_case(&mut app); + + let info: InfoResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::Info {}) + .unwrap(); + assert_eq!( + info, + InfoResponse { + info: ContractVersion { + contract: "crates.io:dao-voting-cw4".to_string(), + version: env!("CARGO_PKG_VERSION").to_string() + } + } + ); + + // Ensure group contract is set + let _group_contract: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::GroupContract {}) + .unwrap(); + + let dao_contract: Addr = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::Dao {}) + .unwrap(); + assert_eq!(dao_contract, Addr::unchecked(DAO_ADDR)); +} + +#[test] +fn test_power_at_height() { + let mut app = App::default(); + let voting_addr = setup_test_case(&mut app); + app.update_block(next_block); + + let cw4_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::GroupContract {}) + .unwrap(); + + let addr1_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(addr1_voting_power.power, Uint128::new(1u128)); + assert_eq!(addr1_voting_power.height, app.block_info().height); + + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { height: None }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(3u128)); + assert_eq!(total_voting_power.height, app.block_info().height); + + // Update ADDR1's weight to 2 + let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![], + add: vec![cw4::Member { + addr: ADDR1.to_string(), + weight: 2, + }], + }; + + // Should still be one as voting power should not update until + // the following block. + let addr1_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(addr1_voting_power.power, Uint128::new(1u128)); + + // Same should be true about the groups contract. + let cw4_power: cw4::MemberResponse = app + .wrap() + .query_wasm_smart( + cw4_addr.clone(), + &cw4::Cw4QueryMsg::Member { + addr: ADDR1.to_string(), + at_height: None, + }, + ) + .unwrap(); + assert_eq!(cw4_power.weight.unwrap(), 1); + + app.execute_contract(Addr::unchecked(DAO_ADDR), cw4_addr.clone(), &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // Should now be 2 + let addr1_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(addr1_voting_power.power, Uint128::new(2u128)); + assert_eq!(addr1_voting_power.height, app.block_info().height); + + // Check we can still get the 1 weight he had last block + let addr1_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: Some(app.block_info().height - 1), + }, + ) + .unwrap(); + assert_eq!(addr1_voting_power.power, Uint128::new(1u128)); + assert_eq!(addr1_voting_power.height, app.block_info().height - 1); + + // Check total power is now 4 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { height: None }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(4u128)); + assert_eq!(total_voting_power.height, app.block_info().height); + + // Check total power for last block is 3 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { + height: Some(app.block_info().height - 1), + }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(3u128)); + assert_eq!(total_voting_power.height, app.block_info().height - 1); + + // Update ADDR1's weight back to 1 + let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![], + add: vec![cw4::Member { + addr: ADDR1.to_string(), + weight: 1, + }], + }; + + app.execute_contract(Addr::unchecked(DAO_ADDR), cw4_addr.clone(), &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // Should now be 1 again + let addr1_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(addr1_voting_power.power, Uint128::new(1u128)); + assert_eq!(addr1_voting_power.height, app.block_info().height); + + // Check total power for current block is now 3 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { height: None }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(3u128)); + assert_eq!(total_voting_power.height, app.block_info().height); + + // Check total power for last block is 4 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { + height: Some(app.block_info().height - 1), + }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(4u128)); + assert_eq!(total_voting_power.height, app.block_info().height - 1); + + // Remove address 2 completely + let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![ADDR2.to_string()], + add: vec![], + }; + + app.execute_contract(Addr::unchecked(DAO_ADDR), cw4_addr.clone(), &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // ADDR2 power is now 0 + let addr2_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR2.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(addr2_voting_power.power, Uint128::zero()); + assert_eq!(addr2_voting_power.height, app.block_info().height); + + // Check total power for current block is now 2 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { height: None }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(2u128)); + assert_eq!(total_voting_power.height, app.block_info().height); + + // Check total power for last block is 3 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { + height: Some(app.block_info().height - 1), + }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(3u128)); + assert_eq!(total_voting_power.height, app.block_info().height - 1); + + // Readd ADDR2 with 10 power + let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![], + add: vec![cw4::Member { + addr: ADDR2.to_string(), + weight: 10, + }], + }; + + app.execute_contract(Addr::unchecked(DAO_ADDR), cw4_addr, &msg, &[]) + .unwrap(); + app.update_block(next_block); + + // ADDR2 power is now 10 + let addr2_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR2.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(addr2_voting_power.power, Uint128::new(10u128)); + assert_eq!(addr2_voting_power.height, app.block_info().height); + + // Check total power for current block is now 12 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::TotalPowerAtHeight { height: None }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(12u128)); + assert_eq!(total_voting_power.height, app.block_info().height); + + // Check total power for last block is 2 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr, + &QueryMsg::TotalPowerAtHeight { + height: Some(app.block_info().height - 1), + }, + ) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(2u128)); + assert_eq!(total_voting_power.height, app.block_info().height - 1); +} + +#[test] +fn test_migrate() { + let mut app = App::default(); + + let initial_members = vec![ + cw4::Member { + addr: ADDR1.to_string(), + weight: 1, + }, + cw4::Member { + addr: ADDR2.to_string(), + weight: 1, + }, + cw4::Member { + addr: ADDR3.to_string(), + weight: 1, + }, + ]; + + // Instantiate with no members, error + let voting_id = app.store_code(voting_contract()); + let cw4_id = app.store_code(cw4_contract()); + let msg = InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members, + }, + }; + let voting_addr = app + .instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "voting module", + Some(DAO_ADDR.to_string()), + ) + .unwrap(); + + let power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + + app.execute( + Addr::unchecked(DAO_ADDR), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: voting_addr.to_string(), + new_code_id: voting_id, + msg: to_binary(&MigrateMsg {}).unwrap(), + }), + ) + .unwrap(); + + let new_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr, + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!(new_power, power) +} + +#[test] +fn test_duplicate_member() { + let mut app = App::default(); + let _voting_addr = setup_test_case(&mut app); + let voting_id = app.store_code(voting_contract()); + let cw4_id = app.store_code(cw4_contract()); + // Instantiate with members but have a duplicate + // Total weight is actually 69 but ADDR3 appears twice. + let msg = InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members: vec![ + cw4::Member { + addr: ADDR3.to_string(), // same address above + weight: 19, + }, + cw4::Member { + addr: ADDR1.to_string(), + weight: 25, + }, + cw4::Member { + addr: ADDR2.to_string(), + weight: 25, + }, + cw4::Member { + addr: ADDR3.to_string(), + weight: 19, + }, + ], + }, + }; + // Previous versions voting power was 100, due to no dedup. + // Now we error + // Bug busted : ) + let _voting_addr = app + .instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "voting module", + None, + ) + .unwrap_err(); +} + +#[test] +fn test_zero_voting_power() { + let mut app = App::default(); + let voting_addr = setup_test_case(&mut app); + app.update_block(next_block); + + let cw4_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::GroupContract {}) + .unwrap(); + + // check that ADDR4 weight is 0 + let addr4_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR4.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(addr4_voting_power.power, Uint128::new(0)); + assert_eq!(addr4_voting_power.height, app.block_info().height); + + // Update ADDR1's weight to 0 + let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![], + add: vec![cw4::Member { + addr: ADDR1.to_string(), + weight: 0, + }], + }; + app.execute_contract(Addr::unchecked(DAO_ADDR), cw4_addr, &msg, &[]) + .unwrap(); + + // Check ADDR1's power is now 0 + let addr1_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: ADDR1.to_string(), + height: None, + }, + ) + .unwrap(); + assert_eq!(addr1_voting_power.power, Uint128::new(0u128)); + assert_eq!(addr1_voting_power.height, app.block_info().height); + + // Check total power is now 2 + let total_voting_power: TotalPowerAtHeightResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::TotalPowerAtHeight { height: None }) + .unwrap(); + assert_eq!(total_voting_power.power, Uint128::new(2u128)); + assert_eq!(total_voting_power.height, app.block_info().height); +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").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); +} diff --git a/contracts/voting/dao-voting-cw721-roles/.cargo/config b/contracts/voting/dao-voting-cw721-roles/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/voting/dao-voting-cw721-roles/Cargo.toml b/contracts/voting/dao-voting-cw721-roles/Cargo.toml new file mode 100644 index 000000000..dad568389 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "dao-voting-cw721-roles" +authors = ["Jake Hartnell"] +description = "A DAO DAO voting module based on non-transferrable cw721 tokens." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +dao-cw721-extensions = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } +cw721-base = { workspace = true, features = ["library"] } +cw721-controllers = { workspace = true } +cw-ownable = { workspace = true } +cw-paginate-storage = { workspace = true } +cw721 = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw4 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +cw721-roles = { workspace = true } +anyhow = { workspace = true } +dao-testing = { workspace = true } diff --git a/contracts/voting/dao-voting-cw721-roles/README.md b/contracts/voting/dao-voting-cw721-roles/README.md new file mode 100644 index 000000000..f15f4e20a --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/README.md @@ -0,0 +1,3 @@ +# dao-voting-cw721-roles + +This contract works in conjunction with the [cw721-roles contract](../../external/cw721-roles), and allows for a DAO with non-transferrable roles that can have different weights for voting power. 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). diff --git a/contracts/voting/dao-voting-cw721-roles/examples/schema.rs b/contracts/voting/dao-voting-cw721-roles/examples/schema.rs new file mode 100644 index 000000000..0e391c586 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use dao_voting_cw721_roles::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} 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 new file mode 100644 index 000000000..7f9681c2c --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/schema/dao-voting-cw721-roles.json @@ -0,0 +1,378 @@ +{ + "contract_name": "dao-voting-cw721-roles", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "nft_contract" + ], + "properties": { + "nft_contract": { + "description": "Info about the associated NFT contract", + "allOf": [ + { + "$ref": "#/definitions/NftContract" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "MetadataExt": { + "type": "object", + "required": [ + "weight" + ], + "properties": { + "role": { + "description": "Optional on-chain role for this member, can be used by other contracts to enforce permissions", + "type": [ + "string", + "null" + ] + }, + "weight": { + "description": "The voting weight of this role", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "NftContract": { + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "Address of an already instantiated cw721-weighted-roles token contract.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "type": "object", + "required": [ + "code_id", + "initial_nfts", + "label", + "name", + "symbol" + ], + "properties": { + "code_id": { + "description": "Code ID for cw721 token contract.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "initial_nfts": { + "description": "Initial NFTs to mint when instantiating the new cw721 contract. If empty, an error is thrown.", + "type": "array", + "items": { + "$ref": "#/definitions/NftMintMsg" + } + }, + "label": { + "description": "Label to use for instantiated cw721 contract.", + "type": "string" + }, + "name": { + "description": "NFT collection name", + "type": "string" + }, + "symbol": { + "description": "NFT collection symbol", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "NftMintMsg": { + "type": "object", + "required": [ + "extension", + "owner", + "token_id" + ], + "properties": { + "extension": { + "description": "Any custom extension used by this contract", + "allOf": [ + { + "$ref": "#/definitions/MetadataExt" + } + ] + }, + "owner": { + "description": "The owner of the newly minter NFT", + "type": "string" + }, + "token_id": { + "description": "Unique ID of the NFT", + "type": "string" + }, + "token_uri": { + "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "type": "string", + "enum": [] + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "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": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "nft_address" + ], + "properties": { + "nft_address": { + "$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" + } + } + }, + "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" + }, + "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 + } + } + }, + "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-cw721-roles/src/contract.rs b/contracts/voting/dao-voting-cw721-roles/src/contract.rs new file mode 100644 index 000000000..62d6ba185 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/contract.rs @@ -0,0 +1,232 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Reply, Response, StdResult, SubMsg, + WasmMsg, +}; +use cw2::set_contract_version; +use cw4::{MemberResponse, TotalWeightResponse}; +use cw721_base::{ + ExecuteMsg as Cw721ExecuteMsg, InstantiateMsg as Cw721InstantiateMsg, QueryMsg as Cw721QueryMsg, +}; +use cw_ownable::Action; +use cw_utils::parse_reply_instantiate_data; +use dao_cw721_extensions::roles::{ExecuteExt, MetadataExt, QueryExt}; + +use crate::msg::{ExecuteMsg, InstantiateMsg, NftContract, QueryMsg}; +use crate::state::{Config, CONFIG, DAO, INITITIAL_NFTS}; +use crate::ContractError; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-cw721-roles"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const INSTANTIATE_NFT_CONTRACT_REPLY_ID: u64 = 0; + +#[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)?; + + match msg.nft_contract { + NftContract::Existing { address } => { + let config = Config { + nft_address: deps.api.addr_validate(&address)?, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("method", "instantiate") + .add_attribute("nft_contract", address)) + } + NftContract::New { + code_id, + label, + name, + symbol, + initial_nfts, + } => { + // Check there is at least one NFT to initialize + if initial_nfts.is_empty() { + return Err(ContractError::NoInitialNfts {}); + } + + // Save initial NFTs for use in reply + INITITIAL_NFTS.save(deps.storage, &initial_nfts)?; + + // Create instantiate submessage for NFT roles contract + let msg = SubMsg::reply_on_success( + WasmMsg::Instantiate { + code_id, + funds: vec![], + admin: Some(info.sender.to_string()), + label, + msg: to_binary(&Cw721InstantiateMsg { + name, + symbol, + // Admin must be set to contract to mint initial NFTs + minter: env.contract.address.to_string(), + })?, + }, + INSTANTIATE_NFT_CONTRACT_REPLY_ID, + ); + + Ok(Response::default().add_submessage(msg)) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: ExecuteMsg, +) -> Result, ContractError> { + Err(ContractError::NoExecute {}) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => query_config(deps), + QueryMsg::Dao {} => query_dao(deps), + QueryMsg::VotingPowerAtHeight { address, height } => { + query_voting_power_at_height(deps, env, address, height) + } + QueryMsg::TotalPowerAtHeight { height } => query_total_power_at_height(deps, env, height), + QueryMsg::Info {} => query_info(deps), + } +} + +pub fn query_voting_power_at_height( + deps: Deps, + env: Env, + address: String, + at_height: Option, +) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let member: MemberResponse = deps.querier.query_wasm_smart( + config.nft_address, + &Cw721QueryMsg::::Extension { + msg: QueryExt::Member { + addr: address, + at_height, + }, + }, + )?; + + to_binary(&dao_interface::voting::VotingPowerAtHeightResponse { + power: member.weight.unwrap_or(0).into(), + height: at_height.unwrap_or(env.block.height), + }) +} + +pub fn query_total_power_at_height( + deps: Deps, + env: Env, + at_height: Option, +) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let total: TotalWeightResponse = deps.querier.query_wasm_smart( + config.nft_address, + &Cw721QueryMsg::::Extension { + msg: QueryExt::TotalWeight { at_height }, + }, + )?; + + to_binary(&dao_interface::voting::TotalPowerAtHeightResponse { + power: total.weight.into(), + height: at_height.unwrap_or(env.block.height), + }) +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&config) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let dao = DAO.load(deps.storage)?; + to_binary(&dao) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + INSTANTIATE_NFT_CONTRACT_REPLY_ID => { + let res = parse_reply_instantiate_data(msg); + match res { + Ok(res) => { + let dao = DAO.load(deps.storage)?; + let nft_contract = res.contract_address; + + // Save config + let config = Config { + nft_address: deps.api.addr_validate(&nft_contract)?, + }; + CONFIG.save(deps.storage, &config)?; + + let initial_nfts = INITITIAL_NFTS.load(deps.storage)?; + + // Add mint submessages + let mint_submessages: Vec = initial_nfts + .iter() + .flat_map(|nft| -> Result { + Ok(SubMsg::new(WasmMsg::Execute { + contract_addr: nft_contract.clone(), + funds: vec![], + msg: to_binary( + &Cw721ExecuteMsg::::Mint { + token_id: nft.token_id.clone(), + owner: nft.owner.clone(), + token_uri: nft.token_uri.clone(), + extension: MetadataExt { + role: nft.clone().extension.role, + weight: nft.extension.weight, + }, + }, + )?, + })) + }) + .collect::>(); + + // Clear space + INITITIAL_NFTS.remove(deps.storage); + + // Update minter message + let update_minter_msg = WasmMsg::Execute { + contract_addr: nft_contract.clone(), + msg: to_binary( + &Cw721ExecuteMsg::::UpdateOwnership( + Action::TransferOwnership { + new_owner: dao.to_string(), + expiry: None, + }, + ), + )?, + funds: vec![], + }; + + Ok(Response::default() + .add_attribute("method", "instantiate") + .add_attribute("nft_contract", nft_contract) + .add_message(update_minter_msg) + .add_submessages(mint_submessages)) + } + Err(_) => Err(ContractError::NftInstantiateError {}), + } + } + _ => Err(ContractError::UnknownReplyId { id: msg.id }), + } +} diff --git a/contracts/voting/dao-voting-cw721-roles/src/error.rs b/contracts/voting/dao-voting-cw721-roles/src/error.rs new file mode 100644 index 000000000..2fa498222 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/error.rs @@ -0,0 +1,23 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error("Error instantiating cw721-roles contract")] + NftInstantiateError {}, + + #[error("This contract only supports queries")] + NoExecute {}, + + #[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")] + NotOwner {}, + + #[error("Got a submessage reply with unknown id: {id}")] + UnknownReplyId { id: u64 }, +} diff --git a/contracts/voting/dao-voting-cw721-roles/src/lib.rs b/contracts/voting/dao-voting-cw721-roles/src/lib.rs new file mode 100644 index 000000000..d4a73c5be --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/voting/dao-voting-cw721-roles/src/msg.rs b/contracts/voting/dao-voting-cw721-roles/src/msg.rs new file mode 100644 index 000000000..b15099529 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/msg.rs @@ -0,0 +1,55 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use dao_cw721_extensions::roles::MetadataExt; +use dao_dao_macros::voting_module_query; + +#[cw_serde] +pub struct NftMintMsg { + /// Unique ID of the NFT + pub token_id: String, + /// The owner of the newly minter NFT + pub owner: String, + /// Universal resource identifier for this NFT + /// Should point to a JSON file that conforms to the ERC721 + /// Metadata JSON Schema + pub token_uri: Option, + /// Any custom extension used by this contract + pub extension: MetadataExt, +} + +#[cw_serde] +pub enum NftContract { + Existing { + /// Address of an already instantiated cw721-weighted-roles token contract. + address: String, + }, + New { + /// Code ID for cw721 token contract. + code_id: u64, + /// Label to use for instantiated cw721 contract. + label: String, + /// NFT collection name + name: String, + /// NFT collection symbol + symbol: String, + /// Initial NFTs to mint when instantiating the new cw721 contract. + /// If empty, an error is thrown. + initial_nfts: Vec, + }, +} + +#[cw_serde] +pub struct InstantiateMsg { + /// Info about the associated NFT contract + pub nft_contract: NftContract, +} + +#[cw_serde] +pub enum ExecuteMsg {} + +#[voting_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(crate::state::Config)] + Config {}, +} diff --git a/contracts/voting/dao-voting-cw721-roles/src/state.rs b/contracts/voting/dao-voting-cw721-roles/src/state.rs new file mode 100644 index 000000000..de55f8d3d --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/state.rs @@ -0,0 +1,16 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::Item; + +use crate::msg::NftMintMsg; + +#[cw_serde] +pub struct Config { + pub nft_address: Addr, +} + +pub const CONFIG: Item = Item::new("config"); +pub const DAO: Item = Item::new("dao"); + +// Holds initial NFTs messages during instantiation. +pub const INITITIAL_NFTS: Item> = Item::new("initial_nfts"); diff --git a/contracts/voting/dao-voting-cw721-roles/src/testing/execute.rs b/contracts/voting/dao-voting-cw721-roles/src/testing/execute.rs new file mode 100644 index 000000000..081a2beaf --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/testing/execute.rs @@ -0,0 +1,28 @@ +use cosmwasm_std::Addr; +use cw_multi_test::{App, AppResponse, Executor}; +use dao_cw721_extensions::roles::{ExecuteExt, MetadataExt}; + +use anyhow::Result as AnyResult; + +pub fn mint_nft( + app: &mut App, + cw721: &Addr, + sender: &str, + receiver: &str, + token_id: &str, +) -> AnyResult { + app.execute_contract( + Addr::unchecked(sender), + cw721.clone(), + &cw721_base::ExecuteMsg::::Mint { + token_id: token_id.to_string(), + owner: receiver.to_string(), + token_uri: None, + extension: MetadataExt { + role: Some("admin".to_string()), + weight: 1, + }, + }, + &[], + ) +} diff --git a/contracts/voting/dao-voting-cw721-roles/src/testing/instantiate.rs b/contracts/voting/dao-voting-cw721-roles/src/testing/instantiate.rs new file mode 100644 index 000000000..59cabddc7 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/testing/instantiate.rs @@ -0,0 +1,24 @@ +use cosmwasm_std::Addr; +use cw_multi_test::{App, Executor}; +use dao_testing::contracts::cw721_roles_contract; + +pub fn instantiate_cw721_roles(app: &mut App, sender: &str, minter: &str) -> (Addr, u64) { + let cw721_id = app.store_code(cw721_roles_contract()); + + let cw721_addr = app + .instantiate_contract( + cw721_id, + Addr::unchecked(sender), + &cw721_base::InstantiateMsg { + name: "bad kids".to_string(), + symbol: "bad kids".to_string(), + minter: minter.to_string(), + }, + &[], + "cw721_roles".to_string(), + None, + ) + .unwrap(); + + (cw721_addr, cw721_id) +} diff --git a/contracts/voting/dao-voting-cw721-roles/src/testing/mod.rs b/contracts/voting/dao-voting-cw721-roles/src/testing/mod.rs new file mode 100644 index 000000000..98cebccd9 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/testing/mod.rs @@ -0,0 +1,47 @@ +mod execute; +mod instantiate; +mod queries; +mod tests; + +use cosmwasm_std::Addr; +use cw_multi_test::{App, Executor}; +use dao_testing::contracts::dao_voting_cw721_roles_contract; + +use crate::msg::{InstantiateMsg, NftContract, NftMintMsg}; + +use self::instantiate::instantiate_cw721_roles; + +/// Address used as the owner, instantiator, and minter. +pub(crate) const CREATOR_ADDR: &str = "creator"; + +pub(crate) struct CommonTest { + app: App, + module_addr: Addr, +} + +pub(crate) fn setup_test(initial_nfts: Vec) -> CommonTest { + let mut app = App::default(); + let module_id = app.store_code(dao_voting_cw721_roles_contract()); + + let (_, cw721_id) = instantiate_cw721_roles(&mut app, CREATOR_ADDR, CREATOR_ADDR); + let module_addr = app + .instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + nft_contract: NftContract::New { + code_id: cw721_id, + label: "cw721-roles".to_string(), + name: "Job Titles".to_string(), + symbol: "TITLES".to_string(), + initial_nfts, + }, + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); + + CommonTest { app, module_addr } +} diff --git a/contracts/voting/dao-voting-cw721-roles/src/testing/queries.rs b/contracts/voting/dao-voting-cw721-roles/src/testing/queries.rs new file mode 100644 index 000000000..dfc3f3468 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/testing/queries.rs @@ -0,0 +1,52 @@ +use cosmwasm_std::{Addr, StdResult}; +use cw_multi_test::App; +use dao_cw721_extensions::roles::QueryExt; +use dao_interface::voting::{ + InfoResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; + +use crate::{msg::QueryMsg, state::Config}; + +pub fn query_config(app: &App, module: &Addr) -> StdResult { + let config = app.wrap().query_wasm_smart(module, &QueryMsg::Config {})?; + Ok(config) +} + +pub fn query_voting_power( + app: &App, + 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: &App, + module: &Addr, + height: Option, +) -> StdResult { + let power = app + .wrap() + .query_wasm_smart(module, &QueryMsg::TotalPowerAtHeight { height })?; + Ok(power) +} + +pub fn query_info(app: &App, module: &Addr) -> StdResult { + let info = app.wrap().query_wasm_smart(module, &QueryMsg::Info {})?; + Ok(info) +} + +pub fn query_minter(app: &App, nft: &Addr) -> StdResult { + let minter = app + .wrap() + .query_wasm_smart(nft, &cw721_base::QueryMsg::::Minter {})?; + Ok(minter) +} diff --git a/contracts/voting/dao-voting-cw721-roles/src/testing/tests.rs b/contracts/voting/dao-voting-cw721-roles/src/testing/tests.rs new file mode 100644 index 000000000..78753a430 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-roles/src/testing/tests.rs @@ -0,0 +1,126 @@ +use cosmwasm_std::{Addr, Uint128}; +use cw_multi_test::{App, Executor}; +use dao_cw721_extensions::roles::MetadataExt; +use dao_testing::contracts::dao_voting_cw721_roles_contract; + +use crate::{ + msg::{InstantiateMsg, NftContract, NftMintMsg}, + state::Config, + testing::{ + execute::mint_nft, + queries::{query_config, query_info, query_minter, query_total_power, query_voting_power}, + }, +}; + +use super::{instantiate::instantiate_cw721_roles, setup_test, CommonTest, CREATOR_ADDR}; + +#[test] +fn test_info_query_works() -> anyhow::Result<()> { + let CommonTest { + app, module_addr, .. + } = setup_test(vec![NftMintMsg { + token_id: "1".to_string(), + owner: CREATOR_ADDR.to_string(), + token_uri: None, + extension: MetadataExt { + role: None, + weight: 1, + }, + }]); + let info = query_info(&app, &module_addr)?; + assert_eq!(info.info.version, env!("CARGO_PKG_VERSION").to_string()); + Ok(()) +} + +#[test] +#[should_panic(expected = "New cw721-roles contract must be instantiated with at least one NFT")] +fn test_instantiate_no_roles_fails() { + setup_test(vec![]); +} + +#[test] +fn test_use_existing_nft_contract() { + let mut app = App::default(); + let module_id = app.store_code(dao_voting_cw721_roles_contract()); + + let (cw721_addr, _) = instantiate_cw721_roles(&mut app, CREATOR_ADDR, CREATOR_ADDR); + let module_addr = app + .instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + nft_contract: NftContract::Existing { + address: cw721_addr.clone().to_string(), + }, + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); + + // Get total power + let total = query_total_power(&app, &module_addr, None).unwrap(); + assert_eq!(total.power, Uint128::zero()); + + // Creator mints themselves a new NFT + mint_nft(&mut app, &cw721_addr, CREATOR_ADDR, CREATOR_ADDR, "1").unwrap(); + + // Get voting power for creator + let vp = query_voting_power(&app, &module_addr, CREATOR_ADDR, None).unwrap(); + assert_eq!(vp.power, Uint128::new(1)); +} + +#[test] +fn test_voting_queries() { + let CommonTest { + mut app, + module_addr, + .. + } = setup_test(vec![NftMintMsg { + token_id: "1".to_string(), + owner: CREATOR_ADDR.to_string(), + token_uri: None, + extension: MetadataExt { + role: Some("admin".to_string()), + weight: 1, + }, + }]); + + // Get config + let config: Config = query_config(&app, &module_addr).unwrap(); + let cw721_addr = config.nft_address; + + // Get NFT minter + let minter = query_minter(&app, &cw721_addr.clone()).unwrap(); + // Minter should be the contract that instantiated the cw721 contract. + // In the test setup, this is the module_addr but would normally be + // the dao-core contract. + assert_eq!(minter.minter, Some(module_addr.to_string())); + + // Get total power + let total = query_total_power(&app, &module_addr, None).unwrap(); + assert_eq!(total.power, Uint128::new(1)); + + // Get voting power for creator + let vp = query_voting_power(&app, &module_addr, CREATOR_ADDR, None).unwrap(); + assert_eq!(vp.power, Uint128::new(1)); + + // Mint a new NFT + mint_nft( + &mut app, + &cw721_addr, + module_addr.as_ref(), + CREATOR_ADDR, + "2", + ) + .unwrap(); + + // Get total power + let total = query_total_power(&app, &module_addr, None).unwrap(); + assert_eq!(total.power, Uint128::new(2)); + + // Get voting power for creator + let vp = query_voting_power(&app, &module_addr, CREATOR_ADDR, None).unwrap(); + assert_eq!(vp.power, Uint128::new(2)); +} diff --git a/contracts/voting/dao-voting-cw721-staked/.cargo/config b/contracts/voting/dao-voting-cw721-staked/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/voting/dao-voting-cw721-staked/Cargo.toml b/contracts/voting/dao-voting-cw721-staked/Cargo.toml new file mode 100644 index 000000000..a882a1eb4 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "dao-voting-cw721-staked" +authors = ["CypherApe cypherape@protonmail.com"] +description = "A DAO DAO voting module based on staked cw721 tokens." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } +cw721 = { workspace = true } +cw721-base = { workspace = true, features = ["library"] } +cw721-controllers = { workspace = true } +cw-paginate-storage = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +dao-voting = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = { workspace = true } +dao-testing = { workspace = true } diff --git a/contracts/voting/dao-voting-cw721-staked/README.md b/contracts/voting/dao-voting-cw721-staked/README.md new file mode 100644 index 000000000..015c8d32d --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/README.md @@ -0,0 +1,8 @@ +# Stake CW721 + +This is a basic implementation of a cw721 staking contract. 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). diff --git a/contracts/voting/dao-voting-cw721-staked/examples/schema.rs b/contracts/voting/dao-voting-cw721-staked/examples/schema.rs new file mode 100644 index 000000000..58bd238de --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use dao_voting_cw721_staked::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} 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 new file mode 100644 index 000000000..026b7803e --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/schema/dao-voting-cw721-staked.json @@ -0,0 +1,1143 @@ +{ + "contract_name": "dao-voting-cw721-staked", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "nft_contract" + ], + "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" + } + ] + }, + "nft_contract": { + "description": "Address of the cw721 NFT contract that may be staked.", + "allOf": [ + { + "$ref": "#/definitions/NftContract" + } + ] + }, + "owner": { + "description": "May change unstaking duration and add hooks.", + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "unstaking_duration": { + "description": "Amount of time between unstaking and tokens being avaliable. 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 + } + ] + }, + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "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 + } + ] + }, + "Empty": { + "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" + }, + "NftContract": { + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "Address of an already instantiated cw721 token contract.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "type": "object", + "required": [ + "code_id", + "initial_nfts", + "label", + "name", + "symbol" + ], + "properties": { + "code_id": { + "description": "Code ID for cw721 token contract.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "initial_nfts": { + "description": "Initial NFTs to mint when creating the NFT contract. If empty, an error is thrown.", + "type": "array", + "items": { + "$ref": "#/definitions/NftMintMsg" + } + }, + "label": { + "description": "Label to use for instantiated cw721 contract.", + "type": "string" + }, + "name": { + "description": "NFT collection name", + "type": "string" + }, + "symbol": { + "description": "NFT collection symbol", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "NftMintMsg": { + "type": "object", + "required": [ + "extension", + "owner", + "token_id" + ], + "properties": { + "extension": { + "description": "Any custom extension used by this contract", + "allOf": [ + { + "$ref": "#/definitions/Empty" + } + ] + }, + "owner": { + "description": "The owner of the newly minter NFT", + "type": "string" + }, + "token_id": { + "description": "Unique ID of the NFT", + "type": "string" + }, + "token_uri": { + "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema", + "type": [ + "string", + "null" + ] + } + }, + "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": "Used to stake NFTs. To stake a NFT send a cw721 send message to this contract with the NFT you would like to stake. The `msg` field is ignored.", + "type": "object", + "required": [ + "receive_nft" + ], + "properties": { + "receive_nft": { + "$ref": "#/definitions/Cw721ReceiveMsg" + } + }, + "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 + }, + { + "type": "object", + "required": [ + "claim_nfts" + ], + "properties": { + "claim_nfts": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "owner": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "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 the instantiator this contract (a DAO most likely) may call this method.", + "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 + } + ] + }, + "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" + }, + "Cw721ReceiveMsg": { + "description": "Cw721ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg", + "type": "object", + "required": [ + "msg", + "sender", + "token_id" + ], + "properties": { + "msg": { + "$ref": "#/definitions/Binary" + }, + "sender": { + "type": "string" + }, + "token_id": { + "type": "string" + } + }, + "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": [ + "nft_address" + ], + "properties": { + "nft_address": { + "$ref": "#/definitions/Addr" + }, + "owner": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "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" + }, + "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-cw721-staked/src/contract.rs b/contracts/voting/dao-voting-cw721-staked/src/contract.rs new file mode 100644 index 000000000..a68f3aaff --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/contract.rs @@ -0,0 +1,648 @@ +use crate::hooks::{stake_hook_msgs, unstake_hook_msgs}; +use crate::msg::NftContract; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{ + register_staked_nft, register_unstaked_nfts, Config, ACTIVE_THRESHOLD, CONFIG, DAO, HOOKS, + INITITIAL_NFTS, MAX_CLAIMS, NFT_BALANCES, NFT_CLAIMS, STAKED_NFTS_PER_OWNER, TOTAL_STAKED_NFTS, +}; +use crate::ContractError; +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, Binary, CosmosMsg, Decimal, Deps, DepsMut, Empty, Env, MessageInfo, Reply, + Response, StdResult, SubMsg, Uint128, Uint256, WasmMsg, +}; +use cw2::set_contract_version; +use cw721::{Cw721ReceiveMsg, NumTokensResponse}; +use cw_storage_plus::Bound; +use cw_utils::{parse_reply_instantiate_data, Duration}; +use dao_interface::state::Admin; +use dao_interface::voting::IsActiveResponse; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-cw721-staked"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const INSTANTIATE_NFT_CONTRACT_REPLY_ID: u64 = 0; + +// 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)?; + + let owner = msg + .owner + .as_ref() + .map(|owner| match owner { + Admin::Address { addr } => deps.api.addr_validate(addr), + Admin::CoreModule {} => Ok(info.sender.clone()), + }) + .transpose()?; + + if let Some(active_threshold) = msg.active_threshold.as_ref() { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + if percent > &Decimal::percent(100) || percent.is_zero() { + return Err(ContractError::InvalidActivePercentage {}); + } + } + ActiveThreshold::AbsoluteCount { count } => { + if count.is_zero() { + return Err(ContractError::ZeroActiveCount {}); + } + } + } + ACTIVE_THRESHOLD.save(deps.storage, active_threshold)?; + } + + TOTAL_STAKED_NFTS.save(deps.storage, &Uint128::zero(), env.block.height)?; + + match msg.nft_contract { + NftContract::Existing { address } => { + let config = Config { + owner: owner.clone(), + nft_address: deps.api.addr_validate(&address)?, + unstaking_duration: msg.unstaking_duration, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("method", "instantiate") + .add_attribute("nft_contract", address) + .add_attribute( + "owner", + owner + .map(|a| a.into_string()) + .unwrap_or_else(|| "None".to_string()), + )) + } + NftContract::New { + code_id, + label, + name, + symbol, + initial_nfts, + } => { + // Check there is at least one NFT to initialize + if initial_nfts.is_empty() { + return Err(ContractError::NoInitialNfts {}); + } + + // Save config with empty nft_address + let config = Config { + owner: owner.clone(), + nft_address: Addr::unchecked(""), + unstaking_duration: msg.unstaking_duration, + }; + CONFIG.save(deps.storage, &config)?; + + // Save initial NFTs for use in reply + INITITIAL_NFTS.save(deps.storage, &initial_nfts)?; + + // Create instantiate submessage for NFT roles contract + let msg = SubMsg::reply_on_success( + WasmMsg::Instantiate { + code_id, + funds: vec![], + admin: Some(info.sender.to_string()), + label, + msg: to_binary(&cw721_base::msg::InstantiateMsg { + name, + symbol, + // Admin must be set to contract to mint initial NFTs + minter: env.contract.address.to_string(), + })?, + }, + INSTANTIATE_NFT_CONTRACT_REPLY_ID, + ); + + Ok(Response::default().add_submessage(msg).add_attribute( + "owner", + owner + .map(|a| a.into_string()) + .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, ContractError> { + match msg { + ExecuteMsg::ReceiveNft(msg) => execute_stake(deps, env, info, msg), + ExecuteMsg::Unstake { token_ids } => execute_unstake(deps, env, info, token_ids), + ExecuteMsg::ClaimNfts {} => execute_claim_nfts(deps, env, info), + ExecuteMsg::UpdateConfig { owner, duration } => { + execute_update_config(info, deps, owner, 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_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, + wrapper: Cw721ReceiveMsg, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if info.sender != config.nft_address { + return Err(ContractError::InvalidToken { + received: info.sender, + expected: config.nft_address, + }); + } + let staker = deps.api.addr_validate(&wrapper.sender)?; + register_staked_nft(deps.storage, env.block.height, &staker, &wrapper.token_id)?; + let hook_msgs = stake_hook_msgs(deps.storage, staker.clone(), wrapper.token_id.clone())?; + Ok(Response::default() + .add_submessages(hook_msgs) + .add_attribute("action", "stake") + .add_attribute("from", staker) + .add_attribute("token_id", wrapper.token_id)) +} + +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_hook_msgs(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| -> StdResult { + Ok(cosmwasm_std::WasmMsg::Execute { + contract_addr: config.nft_address.to_string(), + msg: to_binary(&cw721::Cw721ExecuteMsg::TransferNft { + recipient: info.sender.to_string(), + token_id, + })?, + funds: vec![], + }) + }) + .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| -> StdResult { + Ok(WasmMsg::Execute { + contract_addr: config.nft_address.to_string(), + msg: to_binary(&cw721::Cw721ExecuteMsg::TransferNft { + recipient: info.sender.to_string(), + token_id: nft, + })?, + funds: vec![], + } + .into()) + }) + .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, + new_owner: Option, + duration: Option, +) -> Result { + let mut config: Config = CONFIG.load(deps.storage)?; + + if config.owner.map_or(true, |owner| owner != info.sender) { + return Err(ContractError::NotOwner {}); + } + + let new_owner = new_owner + .map(|new_owner| deps.api.addr_validate(&new_owner)) + .transpose()?; + + config.owner = new_owner; + config.unstaking_duration = duration; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("action", "update_config") + .add_attribute( + "owner", + config + .owner + .map(|a| a.to_string()) + .unwrap_or_else(|| "none".to_string()), + ) + .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 config: Config = CONFIG.load(deps.storage)?; + if config.owner.map_or(true, |owner| owner != info.sender) { + return Err(ContractError::NotOwner {}); + } + + 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 config: Config = CONFIG.load(deps.storage)?; + if config.owner.map_or(true, |owner| owner != info.sender) { + return Err(ContractError::NotOwner {}); + } + + 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 {}); + } + + if let Some(active_threshold) = new_active_threshold { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + if percent > Decimal::percent(100) || percent.is_zero() { + return Err(ContractError::InvalidActivePercentage {}); + } + } + ActiveThreshold::AbsoluteCount { count } => { + if count.is_zero() { + return Err(ContractError::ZeroActiveCount {}); + } + } + } + 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_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: NumTokensResponse = deps.querier.query_wasm_smart( + config.nft_address, + &cw721_base::msg::QueryMsg::::NumTokens {}, + )?; + + match threshold { + ActiveThreshold::AbsoluteCount { count } => to_binary(&IsActiveResponse { + active: staked_nfts >= count, + }), + ActiveThreshold::Percentage { percent } => { + // Check if there are any staked NFTs + if staked_nfts.is_zero() { + return to_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.count).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_binary(&IsActiveResponse { + active: staked_nfts >= count, + }) + } + } + } else { + to_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_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_binary(&dao_interface::voting::TotalPowerAtHeightResponse { power, height }) +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&config) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let dao = DAO.load(deps.storage)?; + to_binary(&dao) +} + +pub fn query_nft_claims(deps: Deps, address: String) -> StdResult { + to_binary(&NFT_CLAIMS.query_claims(deps, &deps.api.addr_validate(&address)?)?) +} + +pub fn query_hooks(deps: Deps) -> StdResult { + to_binary(&HOOKS.query_hooks(deps)?) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_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_binary(&range?) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + INSTANTIATE_NFT_CONTRACT_REPLY_ID => { + let res = parse_reply_instantiate_data(msg); + match res { + Ok(res) => { + let dao = DAO.load(deps.storage)?; + let nft_contract = res.contract_address; + + // Save NFT contract to config + let mut config = CONFIG.load(deps.storage)?; + config.nft_address = deps.api.addr_validate(&nft_contract)?; + CONFIG.save(deps.storage, &config)?; + + let initial_nfts = INITITIAL_NFTS.load(deps.storage)?; + + // Add mint submessages + let mint_submessages: Vec = initial_nfts + .iter() + .flat_map(|nft| -> Result { + Ok(SubMsg::new(WasmMsg::Execute { + contract_addr: nft_contract.clone(), + funds: vec![], + msg: to_binary( + &cw721_base::msg::ExecuteMsg::::Mint { + token_id: nft.token_id.clone(), + owner: nft.owner.clone(), + token_uri: nft.token_uri.clone(), + extension: Empty {}, + }, + )?, + })) + }) + .collect::>(); + + // Clear space + INITITIAL_NFTS.remove(deps.storage); + + // Update minter message + let update_minter_msg = WasmMsg::Execute { + contract_addr: nft_contract.clone(), + msg: to_binary( + &cw721_base::msg::ExecuteMsg::::UpdateOwnership( + cw721_base::Action::TransferOwnership { + new_owner: dao.to_string(), + expiry: None, + }, + ), + )?, + funds: vec![], + }; + + Ok(Response::default() + .add_attribute("method", "instantiate") + .add_attribute("nft_contract", nft_contract) + .add_message(update_minter_msg) + .add_submessages(mint_submessages)) + } + Err(_) => Err(ContractError::NftInstantiateError {}), + } + } + _ => Err(ContractError::UnknownReplyId { id: msg.id }), + } +} diff --git a/contracts/voting/dao-voting-cw721-staked/src/error.rs b/contracts/voting/dao-voting-cw721-staked/src/error.rs new file mode 100644 index 000000000..cf2050df3 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/error.rs @@ -0,0 +1,50 @@ +use cosmwasm_std::{Addr, StdError}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error("Can not stake that which has already been staked")] + AlreadyStaked {}, + + #[error(transparent)] + HookError(#[from] cw_controllers::HookError), + + #[error("Active threshold percentage must be greater than 0 and less than 1")] + InvalidActivePercentage {}, + + #[error("Invalid token. Got ({received}), expected ({expected})")] + InvalidToken { received: Addr, expected: Addr }, + + #[error("Error instantiating cw721-roles contract")] + NftInstantiateError {}, + + #[error("New cw721-roles contract must be instantiated with at least one NFT")] + NoInitialNfts {}, + + #[error("Nothing to claim")] + NothingToClaim {}, + + #[error("Only the owner of this contract my execute this message")] + NotOwner {}, + + #[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("Active threshold count must be greater than zero")] + ZeroActiveCount {}, + + #[error("Can't unstake zero NFTs.")] + ZeroUnstake {}, +} diff --git a/contracts/voting/dao-voting-cw721-staked/src/hooks.rs b/contracts/voting/dao-voting-cw721-staked/src/hooks.rs new file mode 100644 index 000000000..b8ec5f175 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/hooks.rs @@ -0,0 +1,156 @@ +use crate::state::HOOKS; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, Addr, StdResult, Storage, SubMsg, WasmMsg}; + +// This is just a helper to properly serialize the above message +#[cw_serde] +pub enum StakeChangedHookMsg { + Stake { addr: Addr, token_id: String }, + Unstake { addr: Addr, token_ids: Vec }, +} + +pub fn stake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + token_id: String, +) -> StdResult> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Stake { addr, token_id }, + ))?; + HOOKS.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.into_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::new(execute)) + }) +} + +pub fn unstake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + token_ids: Vec, +) -> StdResult> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Unstake { addr, token_ids }, + ))?; + + HOOKS.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.into_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::new(execute)) + }) +} + +// This is just a helper to properly serialize the above message +#[cw_serde] +enum StakeChangedExecuteMsg { + StakeChangeHook(StakeChangedHookMsg), +} + +#[cfg(test)] +mod tests { + use crate::{ + contract::execute, + state::{Config, CONFIG}, + }; + + use super::*; + + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + + #[test] + fn test_hooks() { + let mut deps = mock_dependencies(); + + let messages = stake_hook_msgs( + &deps.storage, + Addr::unchecked("ekez"), + "ekez-token".to_string(), + ) + .unwrap(); + assert_eq!(messages.len(), 0); + + let messages = unstake_hook_msgs( + &deps.storage, + Addr::unchecked("ekez"), + vec!["ekez-token".to_string()], + ) + .unwrap(); + assert_eq!(messages.len(), 0); + + // Save a config for the execute messages we're testing. + CONFIG + .save( + deps.as_mut().storage, + &Config { + owner: Some(Addr::unchecked("ekez")), + nft_address: Addr::unchecked("ekez-token"), + 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_hook_msgs( + &deps.storage, + Addr::unchecked("ekez"), + "ekez-token".to_string(), + ) + .unwrap(); + assert_eq!(messages.len(), 1); + + let messages = unstake_hook_msgs( + &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_hook_msgs( + &deps.storage, + Addr::unchecked("ekez"), + "ekez-token".to_string(), + ) + .unwrap(); + assert_eq!(messages.len(), 0); + + let messages = unstake_hook_msgs( + &deps.storage, + Addr::unchecked("ekez"), + vec!["ekez-token".to_string()], + ) + .unwrap(); + assert_eq!(messages.len(), 0); + } +} diff --git a/contracts/voting/dao-voting-cw721-staked/src/lib.rs b/contracts/voting/dao-voting-cw721-staked/src/lib.rs new file mode 100644 index 000000000..51ae5c619 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-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 hooks; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/voting/dao-voting-cw721-staked/src/msg.rs b/contracts/voting/dao-voting-cw721-staked/src/msg.rs new file mode 100644 index 000000000..a4919db6e --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/msg.rs @@ -0,0 +1,113 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Empty; +use cw721::Cw721ReceiveMsg; +use cw_utils::Duration; +use dao_dao_macros::{active_query, voting_module_query}; +use dao_interface::state::Admin; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +#[cw_serde] +pub struct NftMintMsg { + /// Unique ID of the NFT + pub token_id: String, + /// The owner of the newly minter NFT + pub owner: String, + /// Universal resource identifier for this NFT + /// Should point to a JSON file that conforms to the ERC721 + /// Metadata JSON Schema + pub token_uri: Option, + /// Any custom extension used by this contract + pub extension: Empty, +} + +#[cw_serde] +#[allow(clippy::large_enum_variant)] +pub enum NftContract { + Existing { + /// Address of an already instantiated cw721 token contract. + address: String, + }, + New { + /// Code ID for cw721 token contract. + code_id: u64, + /// Label to use for instantiated cw721 contract. + label: String, + /// NFT collection name + name: String, + /// NFT collection symbol + symbol: String, + /// Initial NFTs to mint when creating the NFT contract. + /// If empty, an error is thrown. + initial_nfts: Vec, + }, +} + +#[cw_serde] +pub struct InstantiateMsg { + /// May change unstaking duration and add hooks. + pub owner: Option, + /// Address of the cw721 NFT contract that may be staked. + pub nft_contract: NftContract, + /// Amount of time between unstaking and tokens being + /// avaliable. 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 { + /// Used to stake NFTs. To stake a NFT send a cw721 send message + /// to this contract with the NFT you would like to stake. The + /// `msg` field is ignored. + ReceiveNft(Cw721ReceiveMsg), + /// 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, + }, + ClaimNfts {}, + UpdateConfig { + owner: Option, + duration: Option, + }, + AddHook { + addr: String, + }, + RemoveHook { + addr: String, + }, + /// Sets the active threshold to a new value. Only the + /// instantiator this contract (a DAO most likely) may call this + /// method. + 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-cw721-staked/src/state.rs b/contracts/voting/dao-voting-cw721-staked/src/state.rs new file mode 100644 index 000000000..486285af2 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/state.rs @@ -0,0 +1,108 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Empty, StdError, StdResult, Storage, Uint128}; +use cw721_controllers::NftClaims; +use cw_controllers::Hooks; +use cw_storage_plus::{Item, Map, SnapshotItem, SnapshotMap, Strategy}; +use cw_utils::Duration; +use dao_voting::threshold::ActiveThreshold; + +use crate::{msg::NftMintMsg, ContractError}; + +#[cw_serde] +pub struct Config { + pub owner: Option, + pub nft_address: Addr, + 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"); + +// Holds initial NFTs messages during instantiation. +pub const INITITIAL_NFTS: Item> = Item::new("initial_nfts"); + +/// 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_nft( + storage: &mut dyn Storage, + height: u64, + staker: &Addr, + token_id: &String, +) -> StdResult<()> { + let add_one = |prev: Option| -> StdResult { + prev.unwrap_or_default() + .checked_add(Uint128::new(1)) + .map_err(StdError::overflow) + }; + + STAKED_NFTS_PER_OWNER.save(storage, (staker, token_id), &Empty::default())?; + NFT_BALANCES.update(storage, staker, height, add_one)?; + TOTAL_STAKED_NFTS + .update(storage, height, add_one) + .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-cw721-staked/src/testing/adversarial.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/adversarial.rs new file mode 100644 index 000000000..f8479c1c3 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/adversarial.rs @@ -0,0 +1,175 @@ +use cosmwasm_std::Uint128; +use cw_multi_test::next_block; +use cw_utils::Duration; + +use crate::{ + state::MAX_CLAIMS, + testing::{ + execute::{stake_nft, unstake_nfts}, + instantiate::instantiate_cw721_base, + queries::query_voting_power, + }, +}; + +use super::{ + execute::mint_and_stake_nft, is_error, queries::query_total_and_voting_power, setup_test, + CommonTest, CREATOR_ADDR, +}; + +/// 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, CREATOR_ADDR, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "2")?; + + app.update_block(next_block); + + let (total, voting) = query_total_and_voting_power(&app, &module, CREATOR_ADDR, None)?; + assert_eq!(total, Uint128::new(2)); + assert_eq!(voting, Uint128::new(2)); + + unstake_nfts(&mut app, &module, CREATOR_ADDR, &["1", "2"])?; + + // Unchanged, one block delay. + let (total, voting) = query_total_and_voting_power(&app, &module, CREATOR_ADDR, None)?; + assert_eq!(total, Uint128::new(2)); + assert_eq!(voting, Uint128::new(2)); + + stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "1")?; + stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "2")?; + + // Unchanged. + let (total, voting) = query_total_and_voting_power(&app, &module, CREATOR_ADDR, 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, CREATOR_ADDR, 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, CREATOR_ADDR, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "2")?; + + unstake_nfts(&mut app, &module, CREATOR_ADDR, &["1", "2"])?; + + app.update_block(next_block); + + let (total, voting) = query_total_and_voting_power(&app, &module, CREATOR_ADDR, None)?; + assert_eq!(total, Uint128::zero()); + assert_eq!(voting, Uint128::zero()); + + Ok(()) +} + +/// I can not stake NFTs from a collection other than the one this has +/// been configured for. +#[test] +fn test_stake_wrong_nft() -> anyhow::Result<()> { + let CommonTest { + mut app, module, .. + } = setup_test(None, None); + let other_nft = instantiate_cw721_base(&mut app, CREATOR_ADDR, CREATOR_ADDR); + + let res = mint_and_stake_nft(&mut app, &other_nft, &module, CREATOR_ADDR, "1"); + is_error!(res => "Invalid token."); + + app.update_block(next_block); + let voting = query_voting_power(&app, &module, CREATOR_ADDR, None)?; + assert_eq!(voting.power, Uint128::new(0)); + + 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, CREATOR_ADDR, "1")?; + + // Future voting power will be one under current conditions. + let voting = query_voting_power( + &app, + &module, + CREATOR_ADDR, + 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, CREATOR_ADDR, None)?; + assert_eq!(voting.power, Uint128::new(0)); + + unstake_nfts(&mut app, &module, CREATOR_ADDR, &["1"])?; + + // Future voting power is now zero. + let voting = query_voting_power( + &app, + &module, + CREATOR_ADDR, + 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(None, Some(Duration::Height(1))); + 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, CREATOR_ADDR, i_str)?; + if i < MAX_CLAIMS { + // unstake MAX_CLAMS - 1 NFTs + unstake_nfts(&mut app, &module, CREATOR_ADDR, &[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, CREATOR_ADDR, to_stake_slice); + is_error!(res => "Too many outstanding claims. Claim some tokens before unstaking more."); + Ok(()) +} diff --git a/contracts/voting/dao-voting-cw721-staked/src/testing/execute.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/execute.rs new file mode 100644 index 000000000..de31d35e8 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/execute.rs @@ -0,0 +1,147 @@ +use cosmwasm_std::{Addr, Binary, Empty}; +use cw721::Cw721ExecuteMsg; +use cw_multi_test::{App, AppResponse, Executor}; + +use anyhow::Result as AnyResult; +use cw_utils::Duration; + +use crate::msg::ExecuteMsg; + +// Shorthand for an unchecked address. +macro_rules! addr { + ($x:expr ) => { + Addr::unchecked($x) + }; +} + +pub fn send_nft( + app: &mut App, + cw721: &Addr, + sender: &str, + receiver: &Addr, + token_id: &str, + msg: Binary, +) -> AnyResult { + app.execute_contract( + addr!(sender), + cw721.clone(), + &Cw721ExecuteMsg::SendNft { + contract: receiver.to_string(), + token_id: token_id.to_string(), + msg, + }, + &[], + ) +} + +pub fn mint_nft( + app: &mut App, + cw721: &Addr, + sender: &str, + receiver: &str, + token_id: &str, +) -> AnyResult { + app.execute_contract( + addr!(sender), + cw721.clone(), + &cw721_base::ExecuteMsg::Mint:: { + token_id: token_id.to_string(), + owner: receiver.to_string(), + token_uri: None, + extension: Empty::default(), + }, + &[], + ) +} + +pub fn stake_nft( + app: &mut App, + cw721: &Addr, + module: &Addr, + sender: &str, + token_id: &str, +) -> AnyResult { + send_nft(app, cw721, sender, module, token_id, Binary::default()) +} + +pub fn mint_and_stake_nft( + app: &mut App, + cw721: &Addr, + module: &Addr, + sender: &str, + token_id: &str, +) -> AnyResult<()> { + mint_nft(app, cw721, sender, sender, token_id)?; + stake_nft(app, cw721, module, sender, token_id)?; + Ok(()) +} + +pub fn unstake_nfts( + app: &mut App, + 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 App, + module: &Addr, + sender: &str, + owner: Option<&str>, + duration: Option, +) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::UpdateConfig { + owner: owner.map(str::to_string), + duration, + }, + &[], + ) +} + +pub fn claim_nfts(app: &mut App, module: &Addr, sender: &str) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::ClaimNfts {}, + &[], + ) +} + +pub fn add_hook(app: &mut App, 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 App, + 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-cw721-staked/src/testing/instantiate.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/instantiate.rs new file mode 100644 index 000000000..71896c818 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/instantiate.rs @@ -0,0 +1,21 @@ +use cosmwasm_std::Addr; +use cw_multi_test::{App, Executor}; +use dao_testing::contracts::cw721_base_contract; + +pub fn instantiate_cw721_base(app: &mut App, sender: &str, minter: &str) -> Addr { + let cw721_id = app.store_code(cw721_base_contract()); + + app.instantiate_contract( + cw721_id, + Addr::unchecked(sender), + &cw721_base::InstantiateMsg { + name: "bad kids".to_string(), + symbol: "bad kids".to_string(), + minter: minter.to_string(), + }, + &[], + "cw721_base".to_string(), + None, + ) + .unwrap() +} diff --git a/contracts/voting/dao-voting-cw721-staked/src/testing/mod.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/mod.rs new file mode 100644 index 000000000..d28f52201 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/mod.rs @@ -0,0 +1,61 @@ +mod adversarial; +mod execute; +mod instantiate; +mod queries; +mod tests; + +use cosmwasm_std::Addr; +use cw_multi_test::{App, Executor}; +use cw_utils::Duration; + +use dao_interface::state::Admin; +use dao_testing::contracts::voting_cw721_staked_contract; + +use crate::msg::{InstantiateMsg, NftContract}; + +use self::instantiate::instantiate_cw721_base; + +/// Address used as the owner, instantiator, and minter. +pub(crate) const CREATOR_ADDR: &str = "creator"; + +pub(crate) struct CommonTest { + app: App, + module: Addr, + nft: Addr, +} + +pub(crate) fn setup_test(owner: Option, unstaking_duration: Option) -> CommonTest { + let mut app = App::default(); + let module_id = app.store_code(voting_cw721_staked_contract()); + + let nft = instantiate_cw721_base(&mut app, CREATOR_ADDR, CREATOR_ADDR); + let module = app + .instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner, + nft_contract: NftContract::Existing { + address: nft.to_string(), + }, + unstaking_duration, + active_threshold: None, + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); + CommonTest { app, 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-cw721-staked/src/testing/queries.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/queries.rs new file mode 100644 index 000000000..946d29af7 --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/queries.rs @@ -0,0 +1,102 @@ +use cosmwasm_std::{Addr, StdResult, Uint128}; +use cw721_controllers::NftClaimsResponse; +use cw_controllers::HooksResponse; +use cw_multi_test::App; +use dao_interface::voting::{ + InfoResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; + +use crate::{msg::QueryMsg, state::Config}; + +pub fn query_config(app: &App, module: &Addr) -> StdResult { + let config = app.wrap().query_wasm_smart(module, &QueryMsg::Config {})?; + Ok(config) +} + +pub fn query_claims(app: &App, 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: &App, module: &Addr) -> StdResult { + let hooks = app.wrap().query_wasm_smart(module, &QueryMsg::Hooks {})?; + Ok(hooks) +} + +pub fn query_staked_nfts( + app: &App, + 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: &App, + 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: &App, + module: &Addr, + height: Option, +) -> StdResult { + let power = app + .wrap() + .query_wasm_smart(module, &QueryMsg::TotalPowerAtHeight { height })?; + Ok(power) +} + +pub fn query_info(app: &App, module: &Addr) -> StdResult { + let info = app.wrap().query_wasm_smart(module, &QueryMsg::Info {})?; + Ok(info) +} + +pub fn query_total_and_voting_power( + app: &App, + 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: &App, nft: &Addr, token_id: &str) -> StdResult { + let owner = app.wrap().query_wasm_smart( + nft, + &cw721::Cw721QueryMsg::OwnerOf { + token_id: token_id.to_string(), + include_expired: None, + }, + )?; + Ok(owner) +} diff --git a/contracts/voting/dao-voting-cw721-staked/src/testing/tests.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/tests.rs new file mode 100644 index 000000000..5b72b614c --- /dev/null +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/tests.rs @@ -0,0 +1,900 @@ +use cosmwasm_std::{Addr, Decimal, Empty, Uint128}; +use cw721_controllers::{NftClaim, NftClaimsResponse}; +use cw_multi_test::{next_block, App, Executor}; +use cw_utils::Duration; +use dao_interface::{state::Admin, voting::IsActiveResponse}; +use dao_testing::contracts::{cw721_base_contract, voting_cw721_staked_contract}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +use crate::{ + msg::{ExecuteMsg, InstantiateMsg, NftContract, NftMintMsg, QueryMsg}, + state::{Config, 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, CREATOR_ADDR, +}; + +// I can create new NFT collection when creating a dao-voting-cw721-staked contract +#[test] +fn test_instantiate_with_new_collection() -> anyhow::Result<()> { + let mut app = App::default(); + let module_id = app.store_code(voting_cw721_staked_contract()); + let cw721_id = app.store_code(cw721_base_contract()); + + let module_addr = app + .instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "1".to_string(), + extension: Empty {}, + }], + }, + unstaking_duration: None, + active_threshold: None, + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); + + let config = query_config(&app, &module_addr)?; + let cw721_addr = config.nft_address; + + // Check that the NFT contract was created + let owner = query_nft_owner(&app, &cw721_addr, "1")?; + assert_eq!(owner.owner, CREATOR_ADDR); + + Ok(()) +} + +// 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, CREATOR_ADDR, 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, CREATOR_ADDR, "1")?; + + // Voting powers are not updated until a block has passed. + let (total, personal) = query_total_and_voting_power(&app, &module, CREATOR_ADDR, None)?; + assert!(total.is_zero()); + assert!(personal.is_zero()); + + app.update_block(next_block); + + let (total, personal) = query_total_and_voting_power(&app, &module, CREATOR_ADDR, 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, CREATOR_ADDR, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "2")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "3")?; + + mint_nft(&mut app, &nft, CREATOR_ADDR, friend, "4")?; + mint_nft(&mut app, &nft, CREATOR_ADDR, 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, CREATOR_ADDR, None)?; + assert_eq!(total, Uint128::new(5)); + assert_eq!(personal, Uint128::new(3)); + + unstake_nfts(&mut app, &module, CREATOR_ADDR, &["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, CREATOR_ADDR, 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, CREATOR_ADDR, 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, CREATOR_ADDR, &["4"]); + is_error!(res => "Can not unstake that which you have not staked (unstaking 4)"); + + let res = unstake_nfts(&mut app, &module, CREATOR_ADDR, &["5", "4"]); + is_error!(res => "Can not unstake that which you have not staked (unstaking 5)"); + + let res = unstake_nfts(&mut app, &module, CREATOR_ADDR, &["☯️", "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, CREATOR_ADDR, &["1"]); + is_error!(res => "Can not unstake that which you have not staked (unstaking 1)"); + + 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(Admin::CoreModule {}), Some(Duration::Height(3))); + + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "2")?; + + unstake_nfts(&mut app, &module, CREATOR_ADDR, &["1"])?; + + let claims = query_claims(&app, &module, CREATOR_ADDR)?; + assert_eq!( + claims, + NftClaimsResponse { + nft_claims: vec![NftClaim { + token_id: "1".to_string(), + release_at: cw_utils::Expiration::AtHeight(app.block_info().height + 3) + }] + } + ); + + // Make friend the new owner. + update_config( + &mut app, + &module, + CREATOR_ADDR, + Some("friend"), + Some(Duration::Time(1)), + )?; + + // Existing claims should remain unchanged. + let claims = query_claims(&app, &module, CREATOR_ADDR)?; + 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, CREATOR_ADDR, &["2"])?; + let claims = query_claims(&app, &module, CREATOR_ADDR)?; + 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, CREATOR_ADDR)?; + let claims = query_claims(&app, &module, CREATOR_ADDR)?; + assert_eq!(claims, NftClaimsResponse { nft_claims: vec![] }); + + // Creator can no longer do config updates. + let res = update_config( + &mut app, + &module, + CREATOR_ADDR, + Some("friend"), + Some(Duration::Time(1)), + ); + is_error!(res => "Only the owner of this contract my execute this message"); + + // Friend can still do config updates, and even remove themselves + // as the owner. + update_config(&mut app, &module, "friend", None, None)?; + let config = query_config(&app, &module)?; + assert_eq!( + config, + Config { + owner: None, + nft_address: nft, + unstaking_duration: None + } + ); + + // Friend has removed themselves. + let res = update_config( + &mut app, + &module, + "friend", + Some("friend"), + Some(Duration::Time(1)), + ); + is_error!(res => "Only the owner of this contract my execute this message"); + + 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(Admin::CoreModule {}), Some(Duration::Height(1))); + + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "2")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "3")?; + + let claims = query_claims(&app, &module, CREATOR_ADDR)?; + assert_eq!(claims.nft_claims, vec![]); + + let res = claim_nfts(&mut app, &module, CREATOR_ADDR); + is_error!(res => "Nothing to claim"); + + unstake_nfts(&mut app, &module, CREATOR_ADDR, &["2"])?; + + let claims = query_claims(&app, &module, CREATOR_ADDR)?; + 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, CREATOR_ADDR); + is_error!(res => "Nothing to claim"); + + app.update_block(next_block); + claim_nfts(&mut app, &module, CREATOR_ADDR)?; + + let owner = query_nft_owner(&app, &nft, "2")?; + assert_eq!(owner.owner, CREATOR_ADDR.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(None, Some(Duration::Height(1))); + + for i in 0..MAX_CLAIMS { + let i_str = &i.to_string(); + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, i_str)?; + unstake_nfts(&mut app, &module, CREATOR_ADDR, &[i_str])?; + } + + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "a")?; + let res = unstake_nfts(&mut app, &module, CREATOR_ADDR, &["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(Admin::CoreModule {}), Some(Duration::Height(1))); + + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "2")?; + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "3")?; + + let deardrie = "deardrie"; + mint_nft(&mut app, &nft, CREATOR_ADDR, deardrie, "4")?; + mint_nft(&mut app, &nft, CREATOR_ADDR, 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, CREATOR_ADDR, Some("1".to_string()), Some(0))?; + assert!(nfts.is_empty()); + + let nfts = query_staked_nfts(&app, &module, CREATOR_ADDR, Some("3".to_string()), None)?; + assert!(nfts.is_empty()); + let nfts = query_staked_nfts( + &app, + &module, + CREATOR_ADDR, + Some("3".to_string()), + Some(500), + )?; + assert!(nfts.is_empty()); + + let nfts = query_staked_nfts(&app, &module, CREATOR_ADDR, Some("1".to_string()), Some(2))?; + assert_eq!(nfts, vec!["2".to_string(), "3".to_string()]); + + unstake_nfts(&mut app, &module, CREATOR_ADDR, &["2"])?; + let nfts = query_staked_nfts(&app, &module, CREATOR_ADDR, 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(()) +} + +// The owner may add and remove hooks. +#[test] +fn test_add_remove_hooks() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + } = setup_test( + Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + None, + ); + + add_hook(&mut app, &module, CREATOR_ADDR, "meow")?; + remove_hook(&mut app, &module, CREATOR_ADDR, "meow")?; + + // Minting NFT works if no hooks + mint_and_stake_nft(&mut app, &nft, &module, CREATOR_ADDR, "1").unwrap(); + + // Add a hook to a fake contract called "meow" + add_hook(&mut app, &module, CREATOR_ADDR, "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, CREATOR_ADDR, "1").unwrap_err(); + + let res = add_hook(&mut app, &module, CREATOR_ADDR, "meow"); + is_error!(res => "Given address already registered as a hook"); + + let res = remove_hook(&mut app, &module, CREATOR_ADDR, "blue"); + is_error!(res => "Given address not registered as a hook"); + + let res = add_hook(&mut app, &module, "ekez", "evil"); + is_error!(res => "Only the owner of this contract my execute this message"); + + Ok(()) +} + +#[test] +#[should_panic(expected = "Active threshold count must be greater than zero")] +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()); + + app.instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "1".to_string(), + extension: Empty {}, + }], + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::zero(), + }), + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); +} + +#[test] +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 voting_addr = app + .instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![ + NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "1".to_string(), + extension: Empty {}, + }, + NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "2".to_string(), + extension: Empty {}, + }, + NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "3".to_string(), + extension: Empty {}, + }, + ], + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(3), + }), + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); + + // Get NFT contract address + let nft_addr = query_config(&app, &voting_addr).unwrap().nft_address; + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake NFTs + stake_nft(&mut app, &nft_addr, &voting_addr, CREATOR_ADDR, "1").unwrap(); + stake_nft(&mut app, &nft_addr, &voting_addr, CREATOR_ADDR, "2").unwrap(); + stake_nft(&mut app, &nft_addr, &voting_addr, CREATOR_ADDR, "3").unwrap(); + + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +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 voting_addr = app + .instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "1".to_string(), + extension: Empty {}, + }], + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(20), + }), + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); + + // Get NFT contract address + let nft_addr = query_config(&app, &voting_addr).unwrap().nft_address; + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake NFTs + stake_nft(&mut app, &nft_addr, &voting_addr, CREATOR_ADDR, "1").unwrap(); + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +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 voting_addr = app + .instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![ + NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "1".to_string(), + extension: Empty {}, + }, + NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "2".to_string(), + extension: Empty {}, + }, + NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "3".to_string(), + extension: Empty {}, + }, + NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "4".to_string(), + extension: Empty {}, + }, + NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "5".to_string(), + extension: Empty {}, + }, + ], + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(50), + }), + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); + + // Get NFT contract address + let nft_addr = query_config(&app, &voting_addr).unwrap().nft_address; + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 2 token as creator, should not be active. + stake_nft(&mut app, &nft_addr, &voting_addr, CREATOR_ADDR, "1").unwrap(); + stake_nft(&mut app, &nft_addr, &voting_addr, CREATOR_ADDR, "2").unwrap(); + + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + println!("{:?}", is_active); + assert!(!is_active.active); + + // Stake 1 more token as creator, should now be active. + stake_nft(&mut app, &nft_addr, &voting_addr, CREATOR_ADDR, "3").unwrap(); + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +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 voting_addr = app + .instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "1".to_string(), + extension: Empty {}, + }], + }, + unstaking_duration: None, + active_threshold: None, + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!(resp.active_threshold, None); + + let msg = ExecuteMsg::UpdateActiveThreshold { + new_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + }; + + // Expect failure as sender is not the DAO + app.execute_contract(Addr::unchecked("bob"), voting_addr.clone(), &msg, &[]) + .unwrap_err(); + + // Expect success as sender is the DAO (in this case the creator) + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + voting_addr.clone(), + &msg, + &[], + ) + .unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!( + resp.active_threshold, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100) + }) + ); +} + +#[test] +#[should_panic(expected = "Active threshold percentage must be greater than 0 and less than 1")] +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()); + + app.instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "1".to_string(), + extension: Empty {}, + }], + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(120), + }), + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "Active threshold percentage must be greater than 0 and less than 1")] +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()); + + app.instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![NftMintMsg { + owner: CREATOR_ADDR.to_string(), + token_uri: Some("https://example.com".to_string()), + token_id: "1".to_string(), + extension: Empty {}, + }], + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(0), + }), + }, + &[], + "cw721_voting", + None, + ) + .unwrap(); +} + +#[test] +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()); + + app.instantiate_contract( + module_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + owner: Some(Admin::Address { + addr: CREATOR_ADDR.to_string(), + }), + nft_contract: NftContract::New { + code_id: cw721_id, + label: "Test NFT".to_string(), + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + initial_nfts: vec![], + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(0), + }), + }, + &[], + "cw721_voting", + None, + ) + .unwrap_err(); +} diff --git a/contracts/voting/dao-voting-native-staked/.cargo/config b/contracts/voting/dao-voting-native-staked/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/voting/dao-voting-native-staked/Cargo.toml b/contracts/voting/dao-voting-native-staked/Cargo.toml new file mode 100644 index 000000000..4960e00de --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "dao-voting-native-staked" +authors = ["Callum Anderson "] +description = "A DAO DAO voting module based on staked native tokens. If your chain uses Token Factory, consider using dao-voting-token-factory-staked for additional functionality including creating new 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 = [] + +[dependencies] +cosmwasm-std = { workspace = true, features = ["cosmwasm_1_1"] } +cosmwasm-schema = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw-hooks = { workspace = true } +cw-utils = { workspace = true } +cw-controllers = { workspace = true } +dao-voting = { workspace = true } + +thiserror = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } +cw-paginate-storage = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = { workspace = true } diff --git a/contracts/voting/dao-voting-native-staked/README.md b/contracts/voting/dao-voting-native-staked/README.md new file mode 100644 index 000000000..a9084cc3f --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/README.md @@ -0,0 +1,10 @@ +# CW Native Staked Balance Voting + +Simple native token voting contract which assumes the native denom +provided is not used for staking for securing the network e.g. IBC +denoms or secondary tokens (ION). Staked balances may be queried at an +arbitrary height. 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). + +If your chain uses Token Factory, consider using `dao-voting-token-factory-staked` for additional functionality including creating new tokens. diff --git a/contracts/voting/dao-voting-native-staked/examples/schema.rs b/contracts/voting/dao-voting-native-staked/examples/schema.rs new file mode 100644 index 000000000..f7ad58f28 --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_voting_native_staked::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/voting/dao-voting-native-staked/schema/dao-voting-native-staked.json b/contracts/voting/dao-voting-native-staked/schema/dao-voting-native-staked.json new file mode 100644 index 000000000..c2f74e1e5 --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/schema/dao-voting-native-staked.json @@ -0,0 +1,1085 @@ +{ + "contract_name": "dao-voting-native-staked", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "denom" + ], + "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" + } + ] + }, + "denom": { + "type": "string" + }, + "manager": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "unstaking_duration": { + "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 + } + ] + }, + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "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" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Stakes tokens with the contract to get voting power in the DAO", + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Unstakes tokens so that they begin unbonding", + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the contract configuration", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "manager": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Claims unstaked tokens that have completed the unbonding period", + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the active threshold to a new value. Only the instantiator of this contract (a DAO most likely) may call this method.", + "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 + }, + { + "description": "Adds a hook that fires on staking / unstaking", + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a hook that fires on staking / unstaking", + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "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": [ + "get_config" + ], + "properties": { + "get_config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "claims" + ], + "properties": { + "claims": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_denom" + ], + "properties": { + "get_denom": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "list_stakers" + ], + "properties": { + "list_stakers": { + "type": "object", + "properties": { + "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": [ + "get_hooks" + ], + "properties": { + "get_hooks": { + "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 + }, + { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "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" + } + } + }, + "claims": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ClaimsResponse", + "type": "object", + "required": [ + "claims" + ], + "properties": { + "claims": { + "type": "array", + "items": { + "$ref": "#/definitions/Claim" + } + } + }, + "additionalProperties": false, + "definitions": { + "Claim": { + "type": "object", + "required": [ + "amount", + "release_at" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "release_at": { + "$ref": "#/definitions/Expiration" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "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" + }, + "get_config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + }, + "manager": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "owner": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "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" + }, + "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 + } + ] + } + } + }, + "get_denom": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DenomResponse", + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "get_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetHooksResponse", + "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" + }, + "list_stakers": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ListStakersResponse", + "type": "object", + "required": [ + "stakers" + ], + "properties": { + "stakers": { + "type": "array", + "items": { + "$ref": "#/definitions/StakerBalanceResponse" + } + } + }, + "additionalProperties": false, + "definitions": { + "StakerBalanceResponse": { + "type": "object", + "required": [ + "address", + "balance" + ], + "properties": { + "address": { + "type": "string" + }, + "balance": { + "$ref": "#/definitions/Uint128" + } + }, + "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" + } + } + }, + "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-native-staked/src/contract.rs b/contracts/voting/dao-voting-native-staked/src/contract.rs new file mode 100644 index 000000000..babff8194 --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/src/contract.rs @@ -0,0 +1,565 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + coins, to_binary, BankMsg, BankQuery, Binary, Coin, CosmosMsg, Decimal, Deps, DepsMut, Env, + MessageInfo, Response, StdResult, Uint128, Uint256, +}; +use cw2::set_contract_version; +use cw_controllers::ClaimsResponse; +use cw_utils::{must_pay, Duration}; +use dao_interface::state::Admin; +use dao_interface::voting::{ + IsActiveResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +use crate::error::ContractError; +use crate::msg::{ + DenomResponse, ExecuteMsg, GetHooksResponse, InstantiateMsg, ListStakersResponse, MigrateMsg, + QueryMsg, StakerBalanceResponse, +}; +use crate::state::{ + Config, ACTIVE_THRESHOLD, CLAIMS, CONFIG, DAO, HOOKS, MAX_CLAIMS, STAKED_BALANCES, STAKED_TOTAL, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-native-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); + +fn validate_duration(duration: Option) -> Result<(), ContractError> { + if let Some(unstaking_duration) = duration { + match unstaking_duration { + Duration::Height(height) => { + if height == 0 { + return Err(ContractError::InvalidUnstakingDuration {}); + } + } + Duration::Time(time) => { + if time == 0 { + return Err(ContractError::InvalidUnstakingDuration {}); + } + } + } + } + Ok(()) +} + +#[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)?; + + let owner = msg + .owner + .as_ref() + .map(|owner| match owner { + Admin::Address { addr } => deps.api.addr_validate(addr), + Admin::CoreModule {} => Ok(info.sender.clone()), + }) + .transpose()?; + let manager = msg + .manager + .map(|manager| deps.api.addr_validate(&manager)) + .transpose()?; + + validate_duration(msg.unstaking_duration)?; + + let config = Config { + owner, + manager, + denom: msg.denom.clone(), + unstaking_duration: msg.unstaking_duration, + }; + + CONFIG.save(deps.storage, &config)?; + DAO.save(deps.storage, &info.sender)?; + + if let Some(active_threshold) = msg.active_threshold.as_ref() { + match active_threshold { + ActiveThreshold::AbsoluteCount { count } => { + assert_valid_absolute_count_threshold(deps.as_ref(), &msg.denom, *count)?; + } + ActiveThreshold::Percentage { percent } => { + if *percent > Decimal::percent(100) || *percent <= Decimal::percent(0) { + return Err(ContractError::InvalidActivePercentage {}); + } + } + } + + ACTIVE_THRESHOLD.save(deps.storage, active_threshold)?; + } + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute( + "owner", + config + .owner + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + ) + .add_attribute( + "manager", + config + .manager + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + )) +} + +pub fn assert_valid_absolute_count_threshold( + deps: Deps, + token_denom: &str, + count: Uint128, +) -> Result<(), ContractError> { + if count.is_zero() { + return Err(ContractError::ZeroActiveCount {}); + } + let supply: Coin = deps.querier.query_supply(token_denom.to_string())?; + if count > supply.amount { + return Err(ContractError::InvalidAbsoluteCount {}); + } + Ok(()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Stake {} => execute_stake(deps, env, info), + ExecuteMsg::Unstake { amount } => execute_unstake(deps, env, info, amount), + ExecuteMsg::UpdateConfig { + owner, + manager, + duration, + } => execute_update_config(deps, info, owner, manager, duration), + ExecuteMsg::Claim {} => execute_claim(deps, env, info), + ExecuteMsg::UpdateActiveThreshold { new_threshold } => { + execute_update_active_threshold(deps, env, info, new_threshold) + } + ExecuteMsg::AddHook { addr } => execute_add_hook(deps, env, info, addr), + ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, env, info, addr), + } +} + +pub fn execute_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let amount = must_pay(&info, &config.denom)?; + + STAKED_BALANCES.update( + deps.storage, + &info.sender, + env.block.height, + |balance| -> StdResult { Ok(balance.unwrap_or_default().checked_add(amount)?) }, + )?; + STAKED_TOTAL.update( + deps.storage, + env.block.height, + |total| -> StdResult { Ok(total.unwrap_or_default().checked_add(amount)?) }, + )?; + + Ok(Response::new() + .add_attribute("action", "stake") + .add_attribute("amount", amount.to_string()) + .add_attribute("from", info.sender)) +} + +pub fn execute_unstake( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, +) -> Result { + if amount.is_zero() { + return Err(ContractError::ZeroUnstake {}); + } + + STAKED_BALANCES.update( + deps.storage, + &info.sender, + env.block.height, + |balance| -> Result { + balance + .unwrap_or_default() + .checked_sub(amount) + .map_err(|_e| ContractError::InvalidUnstakeAmount {}) + }, + )?; + STAKED_TOTAL.update( + deps.storage, + env.block.height, + |total| -> Result { + total + .unwrap_or_default() + .checked_sub(amount) + .map_err(|_e| ContractError::InvalidUnstakeAmount {}) + }, + )?; + + let config = CONFIG.load(deps.storage)?; + match config.unstaking_duration { + None => { + let msg = CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: coins(amount.u128(), config.denom), + }); + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "unstake") + .add_attribute("from", info.sender) + .add_attribute("amount", amount) + .add_attribute("claim_duration", "None")) + } + Some(duration) => { + let outstanding_claims = CLAIMS.query_claims(deps.as_ref(), &info.sender)?.claims; + if outstanding_claims.len() >= MAX_CLAIMS as usize { + return Err(ContractError::TooManyClaims {}); + } + + CLAIMS.create_claim( + deps.storage, + &info.sender, + amount, + duration.after(&env.block), + )?; + Ok(Response::new() + .add_attribute("action", "unstake") + .add_attribute("from", info.sender) + .add_attribute("amount", amount) + .add_attribute("claim_duration", format!("{duration}"))) + } + } +} + +pub fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + new_owner: Option, + new_manager: Option, + duration: Option, +) -> Result { + let mut config: Config = CONFIG.load(deps.storage)?; + if Some(info.sender.clone()) != config.owner && Some(info.sender.clone()) != config.manager { + return Err(ContractError::Unauthorized {}); + } + + let new_owner = new_owner + .map(|new_owner| deps.api.addr_validate(&new_owner)) + .transpose()?; + let new_manager = new_manager + .map(|new_manager| deps.api.addr_validate(&new_manager)) + .transpose()?; + + validate_duration(duration)?; + + if Some(info.sender) != config.owner && new_owner != config.owner { + return Err(ContractError::OnlyOwnerCanChangeOwner {}); + }; + + config.owner = new_owner; + config.manager = new_manager; + + config.unstaking_duration = duration; + + CONFIG.save(deps.storage, &config)?; + Ok(Response::new() + .add_attribute("action", "update_config") + .add_attribute( + "owner", + config + .owner + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + ) + .add_attribute( + "manager", + config + .manager + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + )) +} + +pub fn execute_claim( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let release = CLAIMS.claim_tokens(deps.storage, &info.sender, &env.block, None)?; + if release.is_zero() { + return Err(ContractError::NothingToClaim {}); + } + + let config = CONFIG.load(deps.storage)?; + let msg = CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: coins(release.u128(), config.denom), + }); + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "claim") + .add_attribute("from", info.sender) + .add_attribute("amount", release)) +} + +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 {}); + } + + if let Some(active_threshold) = new_active_threshold { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + if percent > Decimal::percent(100) || percent.is_zero() { + return Err(ContractError::InvalidActivePercentage {}); + } + } + ActiveThreshold::AbsoluteCount { count } => { + let denom = CONFIG.load(deps.storage)?.denom; + assert_valid_absolute_count_threshold(deps.as_ref(), &denom, count)?; + } + } + ACTIVE_THRESHOLD.save(deps.storage, &active_threshold)?; + } else { + ACTIVE_THRESHOLD.remove(deps.storage); + } + + Ok(Response::new().add_attribute("action", "update_active_threshold")) +} + +pub fn execute_add_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + addr: String, +) -> Result { + let config: Config = CONFIG.load(deps.storage)?; + if Some(info.sender.clone()) != config.owner && Some(info.sender) != config.manager { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.add_hook(deps.storage, hook)?; + Ok(Response::new() + .add_attribute("action", "add_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_remove_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + addr: String, +) -> Result { + let config: Config = CONFIG.load(deps.storage)?; + if Some(info.sender.clone()) != config.owner && Some(info.sender) != config.manager { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.remove_hook(deps.storage, hook)?; + Ok(Response::new() + .add_attribute("action", "remove_hook") + .add_attribute("hook", addr)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::VotingPowerAtHeight { address, height } => { + to_binary(&query_voting_power_at_height(deps, env, address, height)?) + } + QueryMsg::TotalPowerAtHeight { height } => { + to_binary(&query_total_power_at_height(deps, env, height)?) + } + QueryMsg::Info {} => query_info(deps), + QueryMsg::Dao {} => query_dao(deps), + QueryMsg::Claims { address } => to_binary(&query_claims(deps, address)?), + QueryMsg::GetConfig {} => to_binary(&CONFIG.load(deps.storage)?), + QueryMsg::ListStakers { start_after, limit } => { + query_list_stakers(deps, start_after, limit) + } + QueryMsg::GetDenom {} => query_denom(deps), + QueryMsg::IsActive {} => query_is_active(deps), + QueryMsg::ActiveThreshold {} => query_active_threshold(deps), + QueryMsg::GetHooks {} => to_binary(&query_hooks(deps)?), + } +} + +pub fn query_voting_power_at_height( + deps: Deps, + env: Env, + address: String, + height: Option, +) -> StdResult { + let height = height.unwrap_or(env.block.height); + let address = deps.api.addr_validate(&address)?; + let power = STAKED_BALANCES + .may_load_at_height(deps.storage, &address, height)? + .unwrap_or_default(); + Ok(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 = STAKED_TOTAL + .may_load_at_height(deps.storage, height)? + .unwrap_or_default(); + Ok(TotalPowerAtHeightResponse { power, height }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let dao = DAO.load(deps.storage)?; + to_binary(&dao) +} + +pub fn query_denom(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_binary(&DenomResponse { + denom: config.denom, + }) +} + +pub fn query_claims(deps: Deps, address: String) -> StdResult { + CLAIMS.query_claims(deps, &deps.api.addr_validate(&address)?) +} + +pub fn query_list_stakers( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let start_at = start_after + .map(|addr| deps.api.addr_validate(&addr)) + .transpose()?; + + let stakers = cw_paginate_storage::paginate_snapshot_map( + deps, + &STAKED_BALANCES, + start_at.as_ref(), + limit, + cosmwasm_std::Order::Ascending, + )?; + + let stakers = stakers + .into_iter() + .map(|(address, balance)| StakerBalanceResponse { + address: address.into_string(), + balance, + }) + .collect(); + + to_binary(&ListStakersResponse { stakers }) +} + +pub fn query_is_active(deps: Deps) -> StdResult { + let threshold = ACTIVE_THRESHOLD.may_load(deps.storage)?; + if let Some(threshold) = threshold { + let denom = CONFIG.load(deps.storage)?.denom; + let actual_power = STAKED_TOTAL.may_load(deps.storage)?.unwrap_or_default(); + match threshold { + ActiveThreshold::AbsoluteCount { count } => to_binary(&IsActiveResponse { + active: actual_power >= count, + }), + ActiveThreshold::Percentage { percent } => { + // 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^128] + // as it tracks the balances of a cw20 token which has + // a max supply of 2^128. + // + // with our precision factor being 10^9: + // + // total_power <= 2^128 * 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_potential_power: cosmwasm_std::SupplyResponse = + deps.querier + .query(&cosmwasm_std::QueryRequest::Bank(BankQuery::Supply { + denom, + }))?; + let total_power = total_potential_power + .amount + .amount + .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_power.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(); + to_binary(&IsActiveResponse { + active: actual_power >= count, + }) + } + } + } else { + to_binary(&IsActiveResponse { active: true }) + } +} + +pub fn query_active_threshold(deps: Deps) -> StdResult { + to_binary(&ActiveThresholdResponse { + active_threshold: ACTIVE_THRESHOLD.may_load(deps.storage)?, + }) +} + +pub fn query_hooks(deps: Deps) -> StdResult { + Ok(GetHooksResponse { + hooks: HOOKS.query_hooks(deps)?.hooks, + }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/voting/dao-voting-native-staked/src/error.rs b/contracts/voting/dao-voting-native-staked/src/error.rs new file mode 100644 index 000000000..85638235b --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/src/error.rs @@ -0,0 +1,45 @@ +use cosmwasm_std::StdError; +use cw_utils::PaymentError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + PaymentError(#[from] PaymentError), + + #[error(transparent)] + HookError(#[from] cw_hooks::HookError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Invalid unstaking duration, unstaking duration cannot be 0")] + InvalidUnstakingDuration {}, + + #[error("Nothing to claim")] + NothingToClaim {}, + + #[error("Too many outstanding claims. Claim some tokens before unstaking more.")] + TooManyClaims {}, + + #[error("Only owner can change owner")] + OnlyOwnerCanChangeOwner {}, + + #[error("Absolute count threshold cannot be greater than the total token supply")] + InvalidAbsoluteCount {}, + + #[error("Active threshold percentage must be greater than 0 and less than 1")] + InvalidActivePercentage {}, + + #[error("Can only unstake less than or equal to the amount you have staked")] + InvalidUnstakeAmount {}, + + #[error("Active threshold count must be greater than zero")] + ZeroActiveCount {}, + + #[error("Amount being unstaked must be non-zero")] + ZeroUnstake {}, +} diff --git a/contracts/voting/dao-voting-native-staked/src/hooks.rs b/contracts/voting/dao-voting-native-staked/src/hooks.rs new file mode 100644 index 000000000..a04d3b043 --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/src/hooks.rs @@ -0,0 +1,51 @@ +use crate::state::HOOKS; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, Addr, StdResult, Storage, SubMsg, Uint128, WasmMsg}; + +#[cw_serde] +pub enum StakeChangedHookMsg { + Stake { addr: Addr, amount: Uint128 }, + Unstake { addr: Addr, amount: Uint128 }, +} + +pub fn stake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + amount: Uint128, +) -> StdResult> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Stake { addr, amount }, + ))?; + HOOKS.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::new(execute)) + }) +} + +pub fn unstake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + amount: Uint128, +) -> StdResult> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Unstake { addr, amount }, + ))?; + HOOKS.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::new(execute)) + }) +} + +// This is just a helper to properly serialize the above message +#[cw_serde] +enum StakeChangedExecuteMsg { + StakeChangeHook(StakeChangedHookMsg), +} diff --git a/contracts/voting/dao-voting-native-staked/src/lib.rs b/contracts/voting/dao-voting-native-staked/src/lib.rs new file mode 100644 index 000000000..6c512e72b --- /dev/null +++ b/contracts/voting/dao-voting-native-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 hooks; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/voting/dao-voting-native-staked/src/msg.rs b/contracts/voting/dao-voting-native-staked/src/msg.rs new file mode 100644 index 000000000..b5b0c340c --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/src/msg.rs @@ -0,0 +1,93 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; +use cw_utils::Duration; +use dao_dao_macros::{active_query, voting_module_query}; +use dao_interface::state::Admin; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +#[cw_serde] +pub struct InstantiateMsg { + // Owner can update all configs including changing the owner. This will generally be a DAO. + pub owner: Option, + // Manager can update all configs except changing the owner. This will generally be an operations multisig for a DAO. + pub manager: Option, + // Token denom e.g. ujuno, or some ibc denom + pub denom: String, + // How long until the tokens become liquid again + 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 { + /// Stakes tokens with the contract to get voting power in the DAO + Stake {}, + /// Unstakes tokens so that they begin unbonding + Unstake { amount: Uint128 }, + /// Updates the contract configuration + UpdateConfig { + owner: Option, + manager: Option, + duration: Option, + }, + /// Claims unstaked tokens that have completed the unbonding period + Claim {}, + /// Sets the active threshold to a new value. Only the + /// instantiator of this contract (a DAO most likely) may call this + /// method. + UpdateActiveThreshold { + new_threshold: Option, + }, + /// Adds a hook that fires on staking / unstaking + AddHook { addr: String }, + /// Removes a hook that fires on staking / unstaking + RemoveHook { addr: String }, +} + +#[voting_module_query] +#[active_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(crate::state::Config)] + GetConfig {}, + #[returns(cw_controllers::ClaimsResponse)] + Claims { address: String }, + #[returns(DenomResponse)] + GetDenom {}, + #[returns(ListStakersResponse)] + ListStakers { + start_after: Option, + limit: Option, + }, + #[returns(ActiveThresholdResponse)] + ActiveThreshold {}, + #[returns(GetHooksResponse)] + GetHooks {}, +} + +#[cw_serde] +pub struct MigrateMsg {} + +#[cw_serde] +pub struct ListStakersResponse { + pub stakers: Vec, +} + +#[cw_serde] +pub struct StakerBalanceResponse { + pub address: String, + pub balance: Uint128, +} + +#[cw_serde] +pub struct DenomResponse { + pub denom: String, +} + +#[cw_serde] +pub struct GetHooksResponse { + pub hooks: Vec, +} diff --git a/contracts/voting/dao-voting-native-staked/src/state.rs b/contracts/voting/dao-voting-native-staked/src/state.rs new file mode 100644 index 000000000..dcd63c14b --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/src/state.rs @@ -0,0 +1,48 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_controllers::Claims; +use cw_hooks::Hooks; +use cw_storage_plus::{Item, SnapshotItem, SnapshotMap, Strategy}; +use cw_utils::Duration; +use dao_voting::threshold::ActiveThreshold; + +#[cw_serde] +pub struct Config { + pub owner: Option, + pub manager: Option, + pub denom: String, + pub unstaking_duration: Option, +} + +/// The configuration of this voting contract +pub const CONFIG: Item = Item::new("config"); + +/// The address of the DAO that instantiated this contract +pub const DAO: Item = Item::new("dao"); + +/// Keeps track of staked balances by address over time +pub const STAKED_BALANCES: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "staked_balances", + "staked_balance__checkpoints", + "staked_balance__changelog", + Strategy::EveryBlock, +); + +/// Keeps track of staked total over time +pub const STAKED_TOTAL: SnapshotItem = SnapshotItem::new( + "total_staked", + "total_staked__checkpoints", + "total_staked__changelog", + Strategy::EveryBlock, +); + +/// The maximum number of claims that may be outstanding. +pub const MAX_CLAIMS: u64 = 100; + +pub const CLAIMS: Claims = Claims::new("claims"); + +/// The minimum amount of staked tokens for the DAO to be active +pub const ACTIVE_THRESHOLD: Item = Item::new("active_threshold"); + +/// Hooks to contracts that will receive staking and unstaking messages +pub const HOOKS: Hooks = Hooks::new("hooks"); diff --git a/contracts/voting/dao-voting-native-staked/src/tests.rs b/contracts/voting/dao-voting-native-staked/src/tests.rs new file mode 100644 index 000000000..9a8fef950 --- /dev/null +++ b/contracts/voting/dao-voting-native-staked/src/tests.rs @@ -0,0 +1,1510 @@ +use crate::contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}; +use crate::error::ContractError; +use crate::msg::{ + DenomResponse, ExecuteMsg, GetHooksResponse, InstantiateMsg, ListStakersResponse, MigrateMsg, + QueryMsg, StakerBalanceResponse, +}; +use crate::state::Config; +use cosmwasm_std::testing::{mock_dependencies, mock_env}; +use cosmwasm_std::{coins, Addr, Coin, Decimal, Empty, Uint128}; +use cw_controllers::ClaimsResponse; +use cw_multi_test::{ + custom_app, next_block, App, AppResponse, Contract, ContractWrapper, Executor, +}; +use cw_utils::Duration; +use dao_interface::state::Admin; +use dao_interface::voting::{ + InfoResponse, IsActiveResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +const DAO_ADDR: &str = "dao"; +const ADDR1: &str = "addr1"; +const ADDR2: &str = "addr2"; +const DENOM: &str = "ujuno"; +const INVALID_DENOM: &str = "uinvalid"; +const ODD_DENOM: &str = "uodd"; + +fn staking_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn mock_app() -> App { + custom_app(|r, _a, s| { + r.bank + .init_balance( + s, + &Addr::unchecked(DAO_ADDR), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + ) + .unwrap(); + r.bank + .init_balance( + s, + &Addr::unchecked(ADDR1), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: ODD_DENOM.to_string(), + amount: Uint128::new(5), + }, + ], + ) + .unwrap(); + r.bank + .init_balance( + s, + &Addr::unchecked(ADDR2), + vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + ) + .unwrap(); + }) +} + +fn instantiate_staking(app: &mut App, staking_id: u64, msg: InstantiateMsg) -> Addr { + app.instantiate_contract( + staking_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "Staking", + None, + ) + .unwrap() +} + +fn stake_tokens( + app: &mut App, + staking_addr: Addr, + sender: &str, + amount: u128, + denom: &str, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::Stake {}, + &coins(amount, denom), + ) +} + +fn unstake_tokens( + app: &mut App, + staking_addr: Addr, + sender: &str, + amount: u128, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::Unstake { + amount: Uint128::new(amount), + }, + &[], + ) +} + +fn claim(app: &mut App, staking_addr: Addr, sender: &str) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::Claim {}, + &[], + ) +} + +fn update_config( + app: &mut App, + staking_addr: Addr, + sender: &str, + owner: Option, + manager: Option, + duration: Option, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::UpdateConfig { + owner, + manager, + duration, + }, + &[], + ) +} + +fn get_voting_power_at_height( + app: &mut App, + staking_addr: Addr, + address: String, + height: Option, +) -> VotingPowerAtHeightResponse { + app.wrap() + .query_wasm_smart( + staking_addr, + &QueryMsg::VotingPowerAtHeight { address, height }, + ) + .unwrap() +} + +fn get_total_power_at_height( + app: &mut App, + staking_addr: Addr, + height: Option, +) -> TotalPowerAtHeightResponse { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::TotalPowerAtHeight { height }) + .unwrap() +} + +fn get_config(app: &mut App, staking_addr: Addr) -> Config { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::GetConfig {}) + .unwrap() +} + +fn get_claims(app: &mut App, staking_addr: Addr, address: String) -> ClaimsResponse { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::Claims { address }) + .unwrap() +} + +fn get_balance(app: &mut App, address: &str, denom: &str) -> Uint128 { + app.wrap().query_balance(address, denom).unwrap().amount +} + +#[test] +fn test_instantiate() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Non populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + denom: DENOM.to_string(), + unstaking_duration: None, + active_threshold: None, + }, + ); +} + +#[test] +fn test_instantiate_dao_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let config = get_config(&mut app, addr); + + assert_eq!(config.owner, Some(Addr::unchecked(DAO_ADDR))) +} + +#[test] +fn test_instantiate_no_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let config = get_config(&mut app, addr); + + assert_eq!(config.owner, None); +} + +#[test] +#[should_panic(expected = "Invalid unstaking duration, unstaking duration cannot be 0")] +fn test_instantiate_invalid_unstaking_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + + // Populated fields with height + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(0)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(1), + }), + }, + ); + + // Populated fields with height + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Time(0)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(1), + }), + }, + ); + + // Non populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + denom: DENOM.to_string(), + unstaking_duration: None, + active_threshold: None, + }, + ); +} + +#[test] +#[should_panic(expected = "Must send reserve token 'ujuno'")] +fn test_stake_invalid_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Try and stake an invalid denom + stake_tokens(&mut app, addr, ADDR1, 100, INVALID_DENOM).unwrap(); +} + +#[test] +fn test_stake_valid_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Try and stake an valid denom + stake_tokens(&mut app, addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); +} + +#[test] +#[should_panic(expected = "Can only unstake less than or equal to the amount you have staked")] +fn test_unstake_none_staked() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + unstake_tokens(&mut app, addr, ADDR1, 100).unwrap(); +} + +#[test] +#[should_panic(expected = "Amount being unstaked must be non-zero")] +fn test_unstake_zero_tokens() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + unstake_tokens(&mut app, addr, ADDR1, 0).unwrap(); +} + +#[test] +#[should_panic(expected = "Can only unstake less than or equal to the amount you have staked")] +fn test_unstake_invalid_balance() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Try and unstake too many + unstake_tokens(&mut app, addr, ADDR1, 200).unwrap(); +} + +#[test] +fn test_unstake() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some + unstake_tokens(&mut app, addr.clone(), ADDR1, 75).unwrap(); + + // Query claims + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 1); + app.update_block(next_block); + + // Unstake the rest + unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); + + // Query claims + let claims = get_claims(&mut app, addr, ADDR1.to_string()); + assert_eq!(claims.claims.len(), 2); +} + +#[test] +fn test_unstake_no_unstaking_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: None, + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some tokens + unstake_tokens(&mut app, addr.clone(), ADDR1, 75).unwrap(); + + app.update_block(next_block); + + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked) = 9975 + assert_eq!(balance, Uint128::new(9975)); + + // Unstake the rest + unstake_tokens(&mut app, addr, ADDR1, 25).unwrap(); + + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked 1) + 25 (unstaked 2) = 10000 + assert_eq!(balance, Uint128::new(10000)) +} + +#[test] +#[should_panic(expected = "Nothing to claim")] +fn test_claim_no_claims() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + claim(&mut app, addr, ADDR1).unwrap(); +} + +#[test] +#[should_panic(expected = "Nothing to claim")] +fn test_claim_claim_not_reached() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake them to create the claims + unstake_tokens(&mut app, addr.clone(), ADDR1, 100).unwrap(); + app.update_block(next_block); + + // We have a claim but it isnt reached yet so this will still fail + claim(&mut app, addr, ADDR1).unwrap(); +} + +#[test] +fn test_claim() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some to create the claims + unstake_tokens(&mut app, addr.clone(), ADDR1, 75).unwrap(); + app.update_block(|b| { + b.height += 5; + b.time = b.time.plus_seconds(25); + }); + + // Claim + claim(&mut app, addr.clone(), ADDR1).unwrap(); + + // Query balance + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked) = 9975 + assert_eq!(balance, Uint128::new(9975)); + + // Unstake the rest + unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(50); + }); + + // Claim + claim(&mut app, addr, ADDR1).unwrap(); + + // Query balance + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked 1) + 25 (unstaked 2) = 10000 + assert_eq!(balance, Uint128::new(10000)); +} + +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_update_config_invalid_sender() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // From ADDR2, so not owner or manager + update_config( + &mut app, + addr, + ADDR2, + Some(ADDR1.to_string()), + Some(DAO_ADDR.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "Only owner can change owner")] +fn test_update_config_non_owner_changes_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // ADDR1 is the manager so cannot change the owner + update_config(&mut app, addr, ADDR1, Some(ADDR2.to_string()), None, None).unwrap(); +} + +#[test] +fn test_update_config_as_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Swap owner and manager, change duration + update_config( + &mut app, + addr.clone(), + DAO_ADDR, + Some(ADDR1.to_string()), + Some(DAO_ADDR.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); + + let config = get_config(&mut app, addr); + assert_eq!( + Config { + owner: Some(Addr::unchecked(ADDR1)), + manager: Some(Addr::unchecked(DAO_ADDR)), + unstaking_duration: Some(Duration::Height(10)), + denom: DENOM.to_string(), + }, + config + ); +} + +#[test] +fn test_update_config_as_manager() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Change duration and manager as manager cannot change owner + update_config( + &mut app, + addr.clone(), + ADDR1, + Some(DAO_ADDR.to_string()), + Some(ADDR2.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); + + let config = get_config(&mut app, addr); + assert_eq!( + Config { + owner: Some(Addr::unchecked(DAO_ADDR)), + manager: Some(Addr::unchecked(ADDR2)), + unstaking_duration: Some(Duration::Height(10)), + denom: DENOM.to_string(), + }, + config + ); +} + +#[test] +#[should_panic(expected = "Invalid unstaking duration, unstaking duration cannot be 0")] +fn test_update_config_invalid_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Change duration and manager as manager cannot change owner + update_config( + &mut app, + addr, + ADDR1, + Some(DAO_ADDR.to_string()), + Some(ADDR2.to_string()), + Some(Duration::Height(0)), + ) + .unwrap(); +} + +#[test] +fn test_query_dao() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let msg = QueryMsg::Dao {}; + let dao: Addr = app.wrap().query_wasm_smart(addr, &msg).unwrap(); + assert_eq!(dao, Addr::unchecked(DAO_ADDR)); +} + +#[test] +fn test_query_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let msg = QueryMsg::GetDenom {}; + let denom: DenomResponse = app.wrap().query_wasm_smart(addr, &msg).unwrap(); + assert_eq!(denom.denom, DENOM.to_string()); +} + +#[test] +fn test_query_info() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let msg = QueryMsg::Info {}; + let resp: InfoResponse = app.wrap().query_wasm_smart(addr, &msg).unwrap(); + assert_eq!(resp.info.contract, "crates.io:dao-voting-native-staked"); +} + +#[test] +fn test_query_claims() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 0); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some tokens + unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); + app.update_block(next_block); + + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 1); + + unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); + app.update_block(next_block); + + let claims = get_claims(&mut app, addr, ADDR1.to_string()); + assert_eq!(claims.claims.len(), 2); +} + +#[test] +fn test_query_get_config() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let config = get_config(&mut app, addr); + assert_eq!( + config, + Config { + owner: Some(Addr::unchecked(DAO_ADDR)), + manager: Some(Addr::unchecked(ADDR1)), + unstaking_duration: Some(Duration::Height(5)), + denom: DENOM.to_string(), + } + ) +} + +#[test] +fn test_voting_power_queries() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Total power is 0 + let resp = get_total_power_at_height(&mut app, addr.clone(), None); + assert!(resp.power.is_zero()); + + // ADDR1 has no power, none staked + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + assert!(resp.power.is_zero()); + + // ADDR1 stakes + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Total power is 100 + let resp = get_total_power_at_height(&mut app, addr.clone(), None); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR1 has 100 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR2 still has 0 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), None); + assert!(resp.power.is_zero()); + + // ADDR2 stakes + stake_tokens(&mut app, addr.clone(), ADDR2, 50, DENOM).unwrap(); + app.update_block(next_block); + let prev_height = app.block_info().height - 1; + + // Query the previous height, total 100, ADDR1 100, ADDR2 0 + // Total power is 100 + let resp = get_total_power_at_height(&mut app, addr.clone(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR1 has 100 power + let resp = + get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR2 still has 0 power + let resp = + get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), Some(prev_height)); + assert!(resp.power.is_zero()); + + // For current height, total 150, ADDR1 100, ADDR2 50 + // Total power is 150 + let resp = get_total_power_at_height(&mut app, addr.clone(), None); + assert_eq!(resp.power, Uint128::new(150)); + + // ADDR1 has 100 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR2 now has 50 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), None); + assert_eq!(resp.power, Uint128::new(50)); + + // ADDR1 unstakes half + unstake_tokens(&mut app, addr.clone(), ADDR1, 50).unwrap(); + app.update_block(next_block); + let prev_height = app.block_info().height - 1; + + // Query the previous height, total 150, ADDR1 100, ADDR2 50 + // Total power is 100 + let resp = get_total_power_at_height(&mut app, addr.clone(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(150)); + + // ADDR1 has 100 power + let resp = + get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR2 still has 0 power + let resp = + get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(50)); + + // For current height, total 100, ADDR1 50, ADDR2 50 + // Total power is 100 + let resp = get_total_power_at_height(&mut app, addr.clone(), None); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR1 has 50 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + assert_eq!(resp.power, Uint128::new(50)); + + // ADDR2 now has 50 power + let resp = get_voting_power_at_height(&mut app, addr, ADDR2.to_string(), None); + assert_eq!(resp.power, Uint128::new(50)); +} + +#[test] +fn test_query_list_stakers() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // ADDR1 stakes + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + + // ADDR2 stakes + stake_tokens(&mut app, addr.clone(), ADDR2, 50, DENOM).unwrap(); + + // check entire result set + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::ListStakers { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + let test_res = ListStakersResponse { + stakers: vec![ + StakerBalanceResponse { + address: ADDR1.to_string(), + balance: Uint128::new(100), + }, + StakerBalanceResponse { + address: ADDR2.to_string(), + balance: Uint128::new(50), + }, + ], + }; + + assert_eq!(stakers, test_res); + + // skipped 1, check result + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::ListStakers { + start_after: Some(ADDR1.to_string()), + limit: None, + }, + ) + .unwrap(); + + let test_res = ListStakersResponse { + stakers: vec![StakerBalanceResponse { + address: ADDR2.to_string(), + balance: Uint128::new(50), + }], + }; + + assert_eq!(stakers, test_res); + + // skipped 2, check result. should be nothing + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr, + &QueryMsg::ListStakers { + start_after: Some(ADDR2.to_string()), + limit: None, + }, + ) + .unwrap(); + + assert_eq!(stakers, ListStakersResponse { stakers: vec![] }); +} + +#[test] +#[should_panic(expected = "Active threshold count must be greater than zero")] +fn test_instantiate_zero_active_threshold_count() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::zero(), + }), + }, + ); +} + +#[test] +fn test_active_threshold_absolute_count() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + }, + ); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 100 tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_percent() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(20), + }), + }, + ); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 6000 tokens, now active + stake_tokens(&mut app, addr.clone(), ADDR1, 6000, DENOM).unwrap(); + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_percent_rounds_up() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: ODD_DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(50), + }), + }, + ); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 2 tokens, should not be active. + stake_tokens(&mut app, addr.clone(), ADDR1, 2, ODD_DENOM).unwrap(); + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 1 more token, should now be active. + stake_tokens(&mut app, addr.clone(), ADDR1, 1, ODD_DENOM).unwrap(); + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_none() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Active as no threshold + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_update_active_threshold() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!(resp.active_threshold, None); + + let msg = ExecuteMsg::UpdateActiveThreshold { + new_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + }; + + // Expect failure as sender is not the DAO + app.execute_contract(Addr::unchecked(ADDR1), addr.clone(), &msg, &[]) + .unwrap_err(); + + // Expect success as sender is the DAO + app.execute_contract(Addr::unchecked(DAO_ADDR), addr.clone(), &msg, &[]) + .unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!( + resp.active_threshold, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100) + }) + ); + + // Can't set threshold to invalid value + let msg = ExecuteMsg::UpdateActiveThreshold { + new_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(120), + }), + }; + let err: ContractError = app + .execute_contract(Addr::unchecked(DAO_ADDR), addr.clone(), &msg, &[]) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::InvalidActivePercentage {}); + + // Remove threshold + let msg = ExecuteMsg::UpdateActiveThreshold { + new_threshold: None, + }; + app.execute_contract(Addr::unchecked(DAO_ADDR), addr.clone(), &msg, &[]) + .unwrap(); + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!(resp.active_threshold, None); +} + +#[test] +#[should_panic(expected = "Active threshold percentage must be greater than 0 and less than 1")] +fn test_active_threshold_percentage_gt_100() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(120), + }), + }, + ); +} + +#[test] +#[should_panic(expected = "Active threshold percentage must be greater than 0 and less than 1")] +fn test_active_threshold_percentage_lte_0() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(0), + }), + }, + ); +} + +#[test] +#[should_panic(expected = "Absolute count threshold cannot be greater than the total token supply")] +fn test_active_threshold_absolute_count_invalid() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(301), + }), + }, + ); +} + +#[test] +fn test_add_remove_hooks() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + denom: DENOM.to_string(), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // No hooks exist. + let resp: GetHooksResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::GetHooks {}) + .unwrap(); + assert_eq!(resp.hooks, Vec::::new()); + + // Non-owner can't add hook + let err: ContractError = app + .execute_contract( + Addr::unchecked(ADDR2), + addr.clone(), + &ExecuteMsg::AddHook { + addr: "hook".to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Add a hook. + app.execute_contract( + Addr::unchecked(DAO_ADDR), + addr.clone(), + &ExecuteMsg::AddHook { + addr: "hook".to_string(), + }, + &[], + ) + .unwrap(); + + // One hook exists. + let resp: GetHooksResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::GetHooks {}) + .unwrap(); + assert_eq!(resp.hooks, vec!["hook".to_string()]); + + // Non-owner can't remove hook + let err: ContractError = app + .execute_contract( + Addr::unchecked(ADDR2), + addr.clone(), + &ExecuteMsg::RemoveHook { + addr: "hook".to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Remove hook. + app.execute_contract( + Addr::unchecked(DAO_ADDR), + addr.clone(), + &ExecuteMsg::RemoveHook { + addr: "hook".to_string(), + }, + &[], + ) + .unwrap(); + + // No hook exists. + let resp: GetHooksResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::GetHooks {}) + .unwrap(); + assert_eq!(resp.hooks, Vec::::new()); +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").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); +} diff --git a/contracts/voting/dao-voting-token-factory-staked/.cargo/config b/contracts/voting/dao-voting-token-factory-staked/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/voting/dao-voting-token-factory-staked/Cargo.toml b/contracts/voting/dao-voting-token-factory-staked/Cargo.toml new file mode 100644 index 000000000..51b6eaaf3 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "dao-voting-token-factory-staked" +authors = ["Callum Anderson ", "Noah Saso ", "Jake Hartnell "] +description = "A DAO DAO voting module based on staked token factory or native tokens. Only works with chains that support Token Factory." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true, features = ["cosmwasm_1_1"] } +cosmwasm-schema = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw-utils = { workspace = true } +cw-controllers = { workspace = true } +cw-hooks = { workspace = true } +thiserror = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +cw-paginate-storage = { workspace = true } +token-bindings = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cw-multi-test = { workspace = true } +token-bindings-test = { workspace = true } diff --git a/contracts/voting/dao-voting-token-factory-staked/README.md b/contracts/voting/dao-voting-token-factory-staked/README.md new file mode 100644 index 000000000..f3ca10443 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/README.md @@ -0,0 +1,11 @@ +# Token Factory Staked Balance Voting + +Simple native or token factory based voting contract which assumes the native denom +provided is not used for staking for securing the network e.g. IBC +denoms or secondary tokens (ION). Staked balances may be queried at an +arbitrary height. 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). + +This contract requires having the Token Factory module on your chain, which allows the creation of new native tokens. If your chain does not have this module, use `dao-voting-native-staked` instead. + diff --git a/contracts/voting/dao-voting-token-factory-staked/examples/schema.rs b/contracts/voting/dao-voting-token-factory-staked/examples/schema.rs new file mode 100644 index 000000000..2aa85dbff --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_voting_token_factory_staked::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/voting/dao-voting-token-factory-staked/schema/dao-voting-token-factory-staked.json b/contracts/voting/dao-voting-token-factory-staked/schema/dao-voting-token-factory-staked.json new file mode 100644 index 000000000..00d683076 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/schema/dao-voting-token-factory-staked.json @@ -0,0 +1,1252 @@ +{ + "contract_name": "dao-voting-token-factory-staked", + "contract_version": "2.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "token_info" + ], + "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" + } + ] + }, + "manager": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "anyOf": [ + { + "$ref": "#/definitions/Admin" + }, + { + "type": "null" + } + ] + }, + "token_info": { + "$ref": "#/definitions/TokenInfo" + }, + "unstaking_duration": { + "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 + } + ] + }, + "Admin": { + "description": "Information about the CosmWasm level admin of a contract. Used in conjunction with `ModuleInstantiateInfo` to instantiate modules.", + "oneOf": [ + { + "description": "Set the admin to a specified address.", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the admin as the core module address.", + "type": "object", + "required": [ + "core_module" + ], + "properties": { + "core_module": { + "type": "object", + "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" + }, + "DenomUnit": { + "description": "This maps to cosmos.bank.v1beta1.DenomUnit protobuf struct", + "type": "object", + "required": [ + "aliases", + "denom", + "exponent" + ], + "properties": { + "aliases": { + "description": "aliases is a list of string aliases for the given denom", + "type": "array", + "items": { + "type": "string" + } + }, + "denom": { + "description": "denom represents the string name of the given denom unit (e.g uatom).", + "type": "string" + }, + "exponent": { + "description": "exponent represents power of 10 exponent that one must raise the base_denom to in order to equal the given DenomUnit's denom 1 denom = 1^exponent base_denom (e.g. with a base_denom of uatom, one can create a DenomUnit of 'atom' with exponent = 6, thus: 1 atom = 10^6 uatom).", + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "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 + } + ] + }, + "InitialBalance": { + "type": "object", + "required": [ + "amount", + "mint_to_address" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "mint_to_address": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Metadata": { + "description": "This maps to cosmos.bank.v1beta1.Metadata protobuf struct", + "type": "object", + "required": [ + "denom_units" + ], + "properties": { + "base": { + "description": "base represents the base denom (should be the DenomUnit with exponent = 0).", + "type": [ + "string", + "null" + ] + }, + "denom_units": { + "description": "denom_units represents the list of DenomUnit's for a given coin", + "type": "array", + "items": { + "$ref": "#/definitions/DenomUnit" + } + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "display": { + "description": "display indicates the suggested denom that should be displayed in clients.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "name defines the name of the token (eg: Cosmos Atom)", + "type": [ + "string", + "null" + ] + }, + "symbol": { + "description": "symbol is the token symbol usually shown on exchanges (eg: ATOM). This can be the same as the display.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "NewTokenInfo": { + "type": "object", + "required": [ + "initial_balances", + "subdenom" + ], + "properties": { + "initial_balances": { + "type": "array", + "items": { + "$ref": "#/definitions/InitialBalance" + } + }, + "initial_dao_balance": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/definitions/Metadata" + }, + { + "type": "null" + } + ] + }, + "subdenom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "TokenInfo": { + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "description": "Token denom e.g. ujuno, or some ibc denom.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "$ref": "#/definitions/NewTokenInfo" + } + }, + "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": "Stakes tokens with the contract to get voting power in the DAO", + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Unstakes tokens so that they begin unbonding", + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the contract configuration", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + }, + "manager": { + "type": [ + "string", + "null" + ] + }, + "owner": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Claims unstaked tokens that have completed the unbonding period", + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the active threshold to a new value. Only the instantiator of this contract (a DAO most likely) may call this method.", + "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 + }, + { + "description": "Adds a hook that fires on staking / unstaking", + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a hook that fires on staking / unstaking", + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "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": [ + "get_config" + ], + "properties": { + "get_config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "get_denom" + ], + "properties": { + "get_denom": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "claims" + ], + "properties": { + "claims": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "list_stakers" + ], + "properties": { + "list_stakers": { + "type": "object", + "properties": { + "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": [ + "get_hooks" + ], + "properties": { + "get_hooks": { + "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 + }, + { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "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" + } + } + }, + "claims": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ClaimsResponse", + "type": "object", + "required": [ + "claims" + ], + "properties": { + "claims": { + "type": "array", + "items": { + "$ref": "#/definitions/Claim" + } + } + }, + "additionalProperties": false, + "definitions": { + "Claim": { + "type": "object", + "required": [ + "amount", + "release_at" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "release_at": { + "$ref": "#/definitions/Expiration" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "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" + }, + "get_config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "properties": { + "manager": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "owner": { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "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" + }, + "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 + } + ] + } + } + }, + "get_denom": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DenomResponse", + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + }, + "get_hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetHooksResponse", + "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" + }, + "list_stakers": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ListStakersResponse", + "type": "object", + "required": [ + "stakers" + ], + "properties": { + "stakers": { + "type": "array", + "items": { + "$ref": "#/definitions/StakerBalanceResponse" + } + } + }, + "additionalProperties": false, + "definitions": { + "StakerBalanceResponse": { + "type": "object", + "required": [ + "address", + "balance" + ], + "properties": { + "address": { + "type": "string" + }, + "balance": { + "$ref": "#/definitions/Uint128" + } + }, + "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" + } + } + }, + "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-token-factory-staked/src/contract.rs b/contracts/voting/dao-voting-token-factory-staked/src/contract.rs new file mode 100644 index 000000000..1d434bf24 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/src/contract.rs @@ -0,0 +1,678 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; + +use cosmwasm_std::{ + coins, to_binary, BankMsg, BankQuery, Binary, Coin, CosmosMsg, Decimal, Deps, DepsMut, Env, + MessageInfo, Order, Reply, Response, StdResult, SubMsg, Uint128, Uint256, +}; +use cw2::set_contract_version; +use cw_controllers::ClaimsResponse; +use cw_storage_plus::Bound; +use cw_utils::{maybe_addr, must_pay, Duration}; +use dao_interface::state::Admin; +use dao_interface::voting::{ + IsActiveResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; +use token_bindings::{TokenFactoryMsg, TokenFactoryQuery, TokenMsg, TokenQuerier}; + +use crate::error::ContractError; +use crate::hooks::{stake_hook_msgs, unstake_hook_msgs}; +use crate::msg::{ + DenomResponse, ExecuteMsg, GetHooksResponse, InstantiateMsg, ListStakersResponse, MigrateMsg, + QueryMsg, StakerBalanceResponse, TokenInfo, +}; +use crate::state::{ + Config, ACTIVE_THRESHOLD, CLAIMS, CONFIG, DAO, DENOM, HOOKS, MAX_CLAIMS, STAKED_BALANCES, + STAKED_TOTAL, TOKEN_INSTANTIATION_INFO, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-token-factory-staked"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// Settings for query pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +const CREATE_DENOM_REPLY_ID: u64 = 0; + +// We multiply by this when calculating needed power for being active +// when using active threshold with percent +const PRECISION_FACTOR: u128 = 10u128.pow(9); + +fn validate_duration(duration: Option) -> Result<(), ContractError> { + if let Some(unstaking_duration) = duration { + match unstaking_duration { + Duration::Height(height) => { + if height == 0 { + return Err(ContractError::InvalidUnstakingDuration {}); + } + } + Duration::Time(time) => { + if time == 0 { + return Err(ContractError::InvalidUnstakingDuration {}); + } + } + } + } + Ok(()) +} + +#[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)?; + + let owner = msg + .owner + .as_ref() + .map(|owner| match owner { + Admin::Address { addr } => deps.api.addr_validate(addr), + Admin::CoreModule {} => Ok(info.sender.clone()), + }) + .transpose()?; + let manager = msg + .manager + .map(|manager| deps.api.addr_validate(&manager)) + .transpose()?; + + validate_duration(msg.unstaking_duration)?; + + let config = Config { + owner, + manager, + unstaking_duration: msg.unstaking_duration, + }; + + CONFIG.save(deps.storage, &config)?; + DAO.save(deps.storage, &info.sender)?; + + if let Some(active_threshold) = msg.active_threshold.as_ref() { + if let ActiveThreshold::Percentage { percent } = active_threshold { + if *percent > Decimal::percent(100) || *percent <= Decimal::percent(0) { + return Err(ContractError::InvalidActivePercentage {}); + } + } + ACTIVE_THRESHOLD.save(deps.storage, active_threshold)?; + } + + match msg.token_info { + TokenInfo::Existing { denom } => { + if let Some(ActiveThreshold::AbsoluteCount { count }) = msg.active_threshold { + assert_valid_absolute_count_threshold(deps.as_ref(), &denom, count)?; + } + + DENOM.save(deps.storage, &denom)?; + + Ok(Response::::new() + .add_attribute("action", "instantiate") + .add_attribute("token", "existing_token") + .add_attribute("token_denom", denom) + .add_attribute( + "owner", + config + .owner + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + ) + .add_attribute( + "manager", + config + .manager + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + )) + } + TokenInfo::New(token) => { + if token.subdenom.eq("") { + return Err(ContractError::InvalidSubdenom { + subdenom: token.subdenom, + }); + } + + // Create Token Factory denom SubMsg + let create_denom_msg = SubMsg::reply_on_success( + TokenMsg::CreateDenom { + subdenom: token.clone().subdenom, + metadata: token.clone().metadata, + }, + CREATE_DENOM_REPLY_ID, + ); + + // Save new token info for use in reply + TOKEN_INSTANTIATION_INFO.save(deps.storage, &token)?; + + Ok(Response::::new() + .add_attribute("method", "create_denom") + .add_submessage(create_denom_msg)) + } + } +} + +pub fn assert_valid_absolute_count_threshold( + deps: Deps, + token_denom: &str, + count: Uint128, +) -> Result<(), ContractError> { + if count.is_zero() { + return Err(ContractError::ZeroActiveCount {}); + } + let supply: Coin = deps.querier.query_supply(token_denom.to_string())?; + if count > supply.amount { + return Err(ContractError::InvalidAbsoluteCount {}); + } + Ok(()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::Stake {} => execute_stake(deps, env, info), + ExecuteMsg::Unstake { amount } => execute_unstake(deps, env, info, amount), + ExecuteMsg::UpdateConfig { + owner, + manager, + duration, + } => execute_update_config(deps, info, owner, manager, duration), + ExecuteMsg::Claim {} => execute_claim(deps, env, info), + ExecuteMsg::UpdateActiveThreshold { new_threshold } => { + execute_update_active_threshold(deps, env, info, new_threshold) + } + ExecuteMsg::AddHook { addr } => execute_add_hook(deps, env, info, addr), + ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, env, info, addr), + } +} + +pub fn execute_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result, ContractError> { + let denom = DENOM.load(deps.storage)?; + let amount = must_pay(&info, &denom)?; + + STAKED_BALANCES.update( + deps.storage, + &info.sender, + env.block.height, + |balance| -> StdResult { Ok(balance.unwrap_or_default().checked_add(amount)?) }, + )?; + STAKED_TOTAL.update( + deps.storage, + env.block.height, + |total| -> StdResult { Ok(total.unwrap_or_default().checked_add(amount)?) }, + )?; + let hook_msgs = stake_hook_msgs(deps.storage, info.sender.clone(), amount)?; + + Ok(Response::::new() + .add_submessages(hook_msgs) + .add_attribute("action", "stake") + .add_attribute("amount", amount.to_string()) + .add_attribute("from", info.sender)) +} + +pub fn execute_unstake( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Uint128, +) -> Result, ContractError> { + if amount.is_zero() { + return Err(ContractError::ZeroUnstake {}); + } + + STAKED_BALANCES.update( + deps.storage, + &info.sender, + env.block.height, + |balance| -> Result { + balance + .unwrap_or_default() + .checked_sub(amount) + .map_err(|_e| ContractError::InvalidUnstakeAmount {}) + }, + )?; + STAKED_TOTAL.update( + deps.storage, + env.block.height, + |total| -> Result { + total + .unwrap_or_default() + .checked_sub(amount) + .map_err(|_e| ContractError::InvalidUnstakeAmount {}) + }, + )?; + let hook_msgs = unstake_hook_msgs(deps.storage, info.sender.clone(), amount)?; + + let config = CONFIG.load(deps.storage)?; + let denom = DENOM.load(deps.storage)?; + match config.unstaking_duration { + None => { + let msg = CosmosMsg::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: coins(amount.u128(), denom), + }); + Ok(Response::::new() + .add_message(msg) + .add_submessages(hook_msgs) + .add_attribute("action", "unstake") + .add_attribute("from", info.sender) + .add_attribute("amount", amount) + .add_attribute("claim_duration", "None")) + } + Some(duration) => { + let outstanding_claims = CLAIMS.query_claims(deps.as_ref(), &info.sender)?.claims; + if outstanding_claims.len() >= MAX_CLAIMS as usize { + return Err(ContractError::TooManyClaims {}); + } + + CLAIMS.create_claim( + deps.storage, + &info.sender, + amount, + duration.after(&env.block), + )?; + Ok(Response::::new() + .add_submessages(hook_msgs) + .add_attribute("action", "unstake") + .add_attribute("from", info.sender) + .add_attribute("amount", amount) + .add_attribute("claim_duration", format!("{duration}"))) + } + } +} + +pub fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + new_owner: Option, + new_manager: Option, + duration: Option, +) -> Result, ContractError> { + let mut config: Config = CONFIG.load(deps.storage)?; + if Some(info.sender.clone()) != config.owner && Some(info.sender.clone()) != config.manager { + return Err(ContractError::Unauthorized {}); + } + + let new_owner = new_owner + .map(|new_owner| deps.api.addr_validate(&new_owner)) + .transpose()?; + let new_manager = new_manager + .map(|new_manager| deps.api.addr_validate(&new_manager)) + .transpose()?; + + validate_duration(duration)?; + + if Some(info.sender) != config.owner && new_owner != config.owner { + return Err(ContractError::OnlyOwnerCanChangeOwner {}); + }; + + config.owner = new_owner; + config.manager = new_manager; + + config.unstaking_duration = duration; + + CONFIG.save(deps.storage, &config)?; + Ok(Response::::new() + .add_attribute("action", "update_config") + .add_attribute( + "owner", + config + .owner + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + ) + .add_attribute( + "manager", + config + .manager + .map(|a| a.to_string()) + .unwrap_or_else(|| "None".to_string()), + )) +} + +pub fn execute_claim( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result, ContractError> { + let release = CLAIMS.claim_tokens(deps.storage, &info.sender, &env.block, None)?; + if release.is_zero() { + return Err(ContractError::NothingToClaim {}); + } + + let denom = DENOM.load(deps.storage)?; + let msg = CosmosMsg::::Bank(BankMsg::Send { + to_address: info.sender.to_string(), + amount: coins(release.u128(), denom), + }); + + Ok(Response::::new() + .add_message(msg) + .add_attribute("action", "claim") + .add_attribute("from", info.sender) + .add_attribute("amount", release)) +} + +pub fn execute_update_active_threshold( + deps: DepsMut, + _env: Env, + info: MessageInfo, + new_active_threshold: Option, +) -> Result, ContractError> { + let dao = DAO.load(deps.storage)?; + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + if let Some(active_threshold) = new_active_threshold { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + if percent > Decimal::percent(100) || percent.is_zero() { + return Err(ContractError::InvalidActivePercentage {}); + } + } + ActiveThreshold::AbsoluteCount { count } => { + let denom = DENOM.load(deps.storage)?; + assert_valid_absolute_count_threshold(deps.as_ref(), &denom, count)?; + } + } + ACTIVE_THRESHOLD.save(deps.storage, &active_threshold)?; + } else { + ACTIVE_THRESHOLD.remove(deps.storage); + } + + Ok(Response::::new().add_attribute("action", "update_active_threshold")) +} + +pub fn execute_add_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + addr: String, +) -> Result, ContractError> { + let config: Config = CONFIG.load(deps.storage)?; + if Some(info.sender.clone()) != config.owner && Some(info.sender) != config.manager { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.add_hook(deps.storage, hook)?; + Ok(Response::::new() + .add_attribute("action", "add_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_remove_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + addr: String, +) -> Result, ContractError> { + let config: Config = CONFIG.load(deps.storage)?; + if Some(info.sender.clone()) != config.owner && Some(info.sender) != config.manager { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.remove_hook(deps.storage, hook)?; + Ok(Response::::new() + .add_attribute("action", "remove_hook") + .add_attribute("hook", addr)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::VotingPowerAtHeight { address, height } => { + to_binary(&query_voting_power_at_height(deps, env, address, height)?) + } + QueryMsg::TotalPowerAtHeight { height } => { + to_binary(&query_total_power_at_height(deps, env, height)?) + } + QueryMsg::Info {} => query_info(deps), + QueryMsg::Dao {} => query_dao(deps), + QueryMsg::Claims { address } => to_binary(&query_claims(deps, address)?), + QueryMsg::GetConfig {} => to_binary(&CONFIG.load(deps.storage)?), + QueryMsg::GetDenom {} => to_binary(&DenomResponse { + denom: DENOM.load(deps.storage)?, + }), + QueryMsg::ListStakers { start_after, limit } => { + query_list_stakers(deps, start_after, limit) + } + QueryMsg::IsActive {} => query_is_active(deps), + QueryMsg::ActiveThreshold {} => query_active_threshold(deps), + QueryMsg::GetHooks {} => to_binary(&query_hooks(deps)?), + } +} + +pub fn query_voting_power_at_height( + deps: Deps, + env: Env, + address: String, + height: Option, +) -> StdResult { + let height = height.unwrap_or(env.block.height); + let address = deps.api.addr_validate(&address)?; + let power = STAKED_BALANCES + .may_load_at_height(deps.storage, &address, height)? + .unwrap_or_default(); + Ok(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 = STAKED_TOTAL + .may_load_at_height(deps.storage, height)? + .unwrap_or_default(); + Ok(TotalPowerAtHeightResponse { power, height }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let dao = DAO.load(deps.storage)?; + to_binary(&dao) +} + +pub fn query_claims(deps: Deps, address: String) -> StdResult { + CLAIMS.query_claims(deps, &deps.api.addr_validate(&address)?) +} + +pub fn query_list_stakers( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let addr = maybe_addr(deps.api, start_after)?; + let start = addr.as_ref().map(Bound::exclusive); + + let stakers = STAKED_BALANCES + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + item.map(|(address, balance)| StakerBalanceResponse { + address: address.into_string(), + balance, + }) + }) + .collect::>()?; + + to_binary(&ListStakersResponse { stakers }) +} + +pub fn query_is_active(deps: Deps) -> StdResult { + let threshold = ACTIVE_THRESHOLD.may_load(deps.storage)?; + if let Some(threshold) = threshold { + let denom = DENOM.load(deps.storage)?; + let actual_power = STAKED_TOTAL.may_load(deps.storage)?.unwrap_or_default(); + match threshold { + ActiveThreshold::AbsoluteCount { count } => to_binary(&IsActiveResponse { + active: actual_power >= count, + }), + ActiveThreshold::Percentage { percent } => { + // 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^128] + // as it tracks the balances of a cw20 token which has + // a max supply of 2^128. + // + // with our precision factor being 10^9: + // + // total_power <= 2^128 * 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_potential_power: cosmwasm_std::SupplyResponse = + deps.querier + .query(&cosmwasm_std::QueryRequest::Bank(BankQuery::Supply { + denom, + }))?; + let total_power = total_potential_power + .amount + .amount + .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_power.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(); + to_binary(&IsActiveResponse { + active: actual_power >= count, + }) + } + } + } else { + to_binary(&IsActiveResponse { active: true }) + } +} + +pub fn query_active_threshold(deps: Deps) -> StdResult { + to_binary(&ActiveThresholdResponse { + active_threshold: ACTIVE_THRESHOLD.may_load(deps.storage)?, + }) +} + +pub fn query_hooks(deps: Deps) -> StdResult { + Ok(GetHooksResponse { + hooks: HOOKS.query_hooks(deps)?.hooks, + }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate( + deps: DepsMut, + _env: Env, + _msg: MigrateMsg, +) -> Result, ContractError> { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply( + deps: DepsMut, + env: Env, + msg: Reply, +) -> Result, ContractError> { + match msg.id { + CREATE_DENOM_REPLY_ID => { + // Load info for new token and the DAO's address + let token = TOKEN_INSTANTIATION_INFO.load(deps.storage)?; + let dao = DAO.load(deps.storage)?; + + // Get the new token factory denom + let querier = TokenQuerier::new(&deps.querier); + let denom = querier + .full_denom(env.contract.address.to_string(), token.subdenom)? + .denom; + DENOM.save(deps.storage, &denom)?; + + let mut mint_msgs: Vec = vec![]; + + // Check supply is greater than zero, iterate through initial + // balances and sum them. + let initial_supply = token + .initial_balances + .iter() + .fold(Uint128::zero(), |previous, new_balance| { + previous + new_balance.amount + }); + + // Cannot instantiate with no initial token owners because it would + // immediately lock the DAO. + if initial_supply.is_zero() { + return Err(ContractError::InitialBalancesError {}); + } + + // Mint initial balances + token.initial_balances.iter().for_each(|b| { + mint_msgs.push(TokenMsg::MintTokens { + denom: denom.clone(), + amount: b.amount, + mint_to_address: b.mint_to_address.clone(), + }) + }); + + // Add initial DAO balance to initial_balances if nonzero. + if let Some(initial_dao_balance) = token.initial_dao_balance { + if !initial_dao_balance.is_zero() { + mint_msgs.push(TokenMsg::MintTokens { + denom: denom.clone(), + amount: initial_dao_balance, + mint_to_address: dao.to_string(), + }) + } + } + + // Clear up unneeded storage. + TOKEN_INSTANTIATION_INFO.remove(deps.storage); + + // Update token factory denom admin to be the DAO + let update_token_admin_msg = TokenMsg::ChangeAdmin { + denom: denom.clone(), + new_admin_address: dao.to_string(), + }; + + Ok(Response::new() + .add_attribute("denom", denom) + .add_messages(mint_msgs) + .add_message(update_token_admin_msg)) + } + _ => Err(ContractError::UnknownReplyId { id: msg.id }), + } +} diff --git a/contracts/voting/dao-voting-token-factory-staked/src/error.rs b/contracts/voting/dao-voting-token-factory-staked/src/error.rs new file mode 100644 index 000000000..b1a7351b1 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/src/error.rs @@ -0,0 +1,63 @@ +use cosmwasm_std::StdError; +use cw_utils::{ParseReplyError, PaymentError}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + PaymentError(#[from] PaymentError), + + #[error(transparent)] + ParseReplyError(#[from] ParseReplyError), + + #[error(transparent)] + HookError(#[from] cw_hooks::HookError), + + #[error("Got a submessage reply with unknown id: {id}")] + UnknownReplyId { id: u64 }, + + #[error("Token factory core contract instantiate error")] + TokenFactoryCoreInstantiateError {}, + + #[error("Initial governance token balances must not be empty")] + InitialBalancesError {}, + + #[error("Invalid subdenom: {subdenom:?}")] + InvalidSubdenom { subdenom: String }, + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Invalid unstaking duration, unstaking duration cannot be 0")] + InvalidUnstakingDuration {}, + + #[error("Nothing to claim")] + NothingToClaim {}, + + #[error("Too many outstanding claims. Claim some tokens before unstaking more.")] + TooManyClaims {}, + + #[error("Only owner can change owner")] + OnlyOwnerCanChangeOwner {}, + + #[error("Can only unstake less than or equal to the amount you have staked")] + InvalidUnstakeAmount {}, + + #[error("Amount being unstaked must be non-zero")] + ZeroUnstake {}, + + #[error("Active threshold percentage must be greater than 0 and less than 1")] + InvalidActivePercentage {}, + + #[error("Active threshold count must be greater than zero")] + ZeroActiveCount {}, + + #[error("Absolute count threshold cannot be greater than the total token supply")] + InvalidAbsoluteCount {}, + + #[error("Cannot change the contract's token after it has been set")] + DuplicateToken {}, +} diff --git a/contracts/voting/dao-voting-token-factory-staked/src/hooks.rs b/contracts/voting/dao-voting-token-factory-staked/src/hooks.rs new file mode 100644 index 000000000..b5941ce84 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/src/hooks.rs @@ -0,0 +1,52 @@ +use crate::state::HOOKS; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, Addr, StdResult, Storage, SubMsg, Uint128, WasmMsg}; +use token_bindings::TokenFactoryMsg; + +#[cw_serde] +pub enum StakeChangedHookMsg { + Stake { addr: Addr, amount: Uint128 }, + Unstake { addr: Addr, amount: Uint128 }, +} + +pub fn stake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + amount: Uint128, +) -> StdResult>> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Stake { addr, amount }, + ))?; + HOOKS.prepare_hooks_custom_msg(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::::new(execute)) + }) +} + +pub fn unstake_hook_msgs( + storage: &dyn Storage, + addr: Addr, + amount: Uint128, +) -> StdResult>> { + let msg = to_binary(&StakeChangedExecuteMsg::StakeChangeHook( + StakeChangedHookMsg::Unstake { addr, amount }, + ))?; + HOOKS.prepare_hooks_custom_msg(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + Ok(SubMsg::::new(execute)) + }) +} + +// This is just a helper to properly serialize the above message +#[cw_serde] +enum StakeChangedExecuteMsg { + StakeChangeHook(StakeChangedHookMsg), +} diff --git a/contracts/voting/dao-voting-token-factory-staked/src/lib.rs b/contracts/voting/dao-voting-token-factory-staked/src/lib.rs new file mode 100644 index 000000000..6c512e72b --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-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 hooks; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/voting/dao-voting-token-factory-staked/src/msg.rs b/contracts/voting/dao-voting-token-factory-staked/src/msg.rs new file mode 100644 index 000000000..5d78d22e9 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/src/msg.rs @@ -0,0 +1,117 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; +use cw_utils::Duration; +use dao_dao_macros::{active_query, voting_module_query}; +use dao_interface::state::Admin; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; +use token_bindings::Metadata; + +#[cw_serde] +pub struct InitialBalance { + pub amount: Uint128, + pub mint_to_address: String, +} + +#[cw_serde] +pub struct NewTokenInfo { + pub subdenom: String, + pub metadata: Option, + pub initial_balances: Vec, + pub initial_dao_balance: Option, +} + +#[cw_serde] +pub enum TokenInfo { + Existing { + /// Token denom e.g. ujuno, or some ibc denom. + denom: String, + }, + New(NewTokenInfo), +} + +#[cw_serde] +pub struct InstantiateMsg { + // Owner can update all configs including changing the owner. This will generally be a DAO. + pub owner: Option, + // Manager can update all configs except changing the owner. This will generally be an operations multisig for a DAO. + pub manager: Option, + // New or existing native token to use for voting power. + pub token_info: TokenInfo, + // How long until the tokens become liquid again + 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 { + /// Stakes tokens with the contract to get voting power in the DAO + Stake {}, + /// Unstakes tokens so that they begin unbonding + Unstake { amount: Uint128 }, + /// Updates the contract configuration + UpdateConfig { + owner: Option, + manager: Option, + duration: Option, + }, + /// Claims unstaked tokens that have completed the unbonding period + Claim {}, + /// Sets the active threshold to a new value. Only the + /// instantiator of this contract (a DAO most likely) may call this + /// method. + UpdateActiveThreshold { + new_threshold: Option, + }, + /// Adds a hook that fires on staking / unstaking + AddHook { addr: String }, + /// Removes a hook that fires on staking / unstaking + RemoveHook { addr: String }, +} + +#[voting_module_query] +#[active_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(crate::state::Config)] + GetConfig {}, + #[returns(DenomResponse)] + GetDenom {}, + #[returns(cw_controllers::ClaimsResponse)] + Claims { address: String }, + #[returns(ListStakersResponse)] + ListStakers { + start_after: Option, + limit: Option, + }, + #[returns(ActiveThresholdResponse)] + ActiveThreshold {}, + #[returns(GetHooksResponse)] + GetHooks {}, +} + +#[cw_serde] +pub struct MigrateMsg {} + +#[cw_serde] +pub struct ListStakersResponse { + pub stakers: Vec, +} + +#[cw_serde] +pub struct StakerBalanceResponse { + pub address: String, + pub balance: Uint128, +} + +#[cw_serde] +pub struct DenomResponse { + pub denom: String, +} + +#[cw_serde] +pub struct GetHooksResponse { + pub hooks: Vec, +} diff --git a/contracts/voting/dao-voting-token-factory-staked/src/state.rs b/contracts/voting/dao-voting-token-factory-staked/src/state.rs new file mode 100644 index 000000000..e5a06fed2 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/src/state.rs @@ -0,0 +1,55 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_controllers::Claims; +use cw_hooks::Hooks; +use cw_storage_plus::{Item, SnapshotItem, SnapshotMap, Strategy}; +use cw_utils::Duration; +use dao_voting::threshold::ActiveThreshold; + +use crate::msg::NewTokenInfo; + +#[cw_serde] +pub struct Config { + pub owner: Option, + pub manager: Option, + pub unstaking_duration: Option, +} + +/// The configuration of this voting contract +pub const CONFIG: Item = Item::new("config"); + +/// The address of the DAO this voting contract is connected to +pub const DAO: Item = Item::new("dao"); + +/// The native denom associated with this contract +pub const DENOM: Item = Item::new("denom"); + +/// Keeps track of staked balances by address over time +pub const STAKED_BALANCES: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "staked_balances", + "staked_balance__checkpoints", + "staked_balance__changelog", + Strategy::EveryBlock, +); + +/// Keeps track of staked total over time +pub const STAKED_TOTAL: SnapshotItem = SnapshotItem::new( + "total_staked", + "total_staked__checkpoints", + "total_staked__changelog", + Strategy::EveryBlock, +); + +/// The maximum number of claims that may be outstanding. +pub const MAX_CLAIMS: u64 = 100; + +pub const CLAIMS: Claims = Claims::new("claims"); + +/// The minimum amount of staked tokens for the DAO to be active +pub const ACTIVE_THRESHOLD: Item = Item::new("active_threshold"); + +/// Hooks to contracts that will receive staking and unstaking messages +pub const HOOKS: Hooks = Hooks::new("hooks"); + +/// Temporarily holds token_instantiation_info when creating a new Token Factory denom +pub const TOKEN_INSTANTIATION_INFO: Item = Item::new("token_instantiation_info"); diff --git a/contracts/voting/dao-voting-token-factory-staked/src/tests.rs b/contracts/voting/dao-voting-token-factory-staked/src/tests.rs new file mode 100644 index 000000000..bdd5bfaf8 --- /dev/null +++ b/contracts/voting/dao-voting-token-factory-staked/src/tests.rs @@ -0,0 +1,1619 @@ +use std::marker::PhantomData; + +use crate::contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}; +use crate::msg::{ + DenomResponse, ExecuteMsg, GetHooksResponse, InitialBalance, InstantiateMsg, + ListStakersResponse, MigrateMsg, NewTokenInfo, QueryMsg, StakerBalanceResponse, TokenInfo, +}; +use crate::state::Config; +use crate::ContractError; +use cosmwasm_std::testing::{mock_env, MockApi, MockQuerier, MockStorage}; +use cosmwasm_std::{coins, Addr, Coin, Decimal, OwnedDeps, Uint128}; +use cw_controllers::ClaimsResponse; +use cw_multi_test::{ + next_block, AppResponse, BankSudo, Contract, ContractWrapper, Executor, SudoMsg, +}; +use cw_utils::Duration; +use dao_interface::state::Admin; +use dao_interface::voting::{ + InfoResponse, IsActiveResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; +use token_bindings::{Metadata, TokenFactoryMsg, TokenFactoryQuery}; +use token_bindings_test::TokenFactoryApp as App; + +const DAO_ADDR: &str = "dao"; +const ADDR1: &str = "addr1"; +const ADDR2: &str = "addr2"; +const DENOM: &str = "ujuno"; +const INVALID_DENOM: &str = "uinvalid"; +const ODD_DENOM: &str = "uodd"; + +fn staking_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply); + Box::new(contract) +} + +fn mock_app() -> App { + let mut app = App::new(); + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: DAO_ADDR.to_string(), + amount: vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + })) + .unwrap(); + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: ADDR1.to_string(), + amount: vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: ODD_DENOM.to_string(), + amount: Uint128::new(5), + }, + ], + })) + .unwrap(); + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: ADDR2.to_string(), + amount: vec![ + Coin { + denom: DENOM.to_string(), + amount: Uint128::new(10000), + }, + Coin { + denom: INVALID_DENOM.to_string(), + amount: Uint128::new(10000), + }, + ], + })) + .unwrap(); + app +} + +fn instantiate_staking(app: &mut App, staking_id: u64, msg: InstantiateMsg) -> Addr { + app.instantiate_contract( + staking_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "Staking", + None, + ) + .unwrap() +} + +fn instantiate_staking_error(app: &mut App, staking_id: u64, msg: InstantiateMsg) -> ContractError { + app.instantiate_contract( + staking_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "Staking", + None, + ) + .unwrap_err() + .downcast() + .unwrap() +} + +fn stake_tokens( + app: &mut App, + staking_addr: Addr, + sender: &str, + amount: u128, + denom: &str, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::Stake {}, + &coins(amount, denom), + ) +} + +fn unstake_tokens( + app: &mut App, + staking_addr: Addr, + sender: &str, + amount: u128, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::Unstake { + amount: Uint128::new(amount), + }, + &[], + ) +} + +fn claim(app: &mut App, staking_addr: Addr, sender: &str) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::Claim {}, + &[], + ) +} + +fn update_config( + app: &mut App, + staking_addr: Addr, + sender: &str, + owner: Option, + manager: Option, + duration: Option, +) -> anyhow::Result { + app.execute_contract( + Addr::unchecked(sender), + staking_addr, + &ExecuteMsg::UpdateConfig { + owner, + manager, + duration, + }, + &[], + ) +} + +fn get_voting_power_at_height( + app: &mut App, + staking_addr: Addr, + address: String, + height: Option, +) -> VotingPowerAtHeightResponse { + app.wrap() + .query_wasm_smart( + staking_addr, + &QueryMsg::VotingPowerAtHeight { address, height }, + ) + .unwrap() +} + +fn get_total_power_at_height( + app: &mut App, + staking_addr: Addr, + height: Option, +) -> TotalPowerAtHeightResponse { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::TotalPowerAtHeight { height }) + .unwrap() +} + +fn get_config(app: &mut App, staking_addr: Addr) -> Config { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::GetConfig {}) + .unwrap() +} + +fn get_denom(app: &mut App, staking_addr: Addr) -> DenomResponse { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::GetDenom {}) + .unwrap() +} + +fn get_claims(app: &mut App, staking_addr: Addr, address: String) -> ClaimsResponse { + app.wrap() + .query_wasm_smart(staking_addr, &QueryMsg::Claims { address }) + .unwrap() +} + +fn get_balance(app: &mut App, address: &str, denom: &str) -> Uint128 { + app.wrap().query_balance(address, denom).unwrap().amount +} + +#[test] +fn test_instantiate_existing() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Non populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: None, + active_threshold: None, + }, + ); +} + +#[test] +fn test_instantiate_new_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + + // Populated fields + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::New(NewTokenInfo { + subdenom: DENOM.to_string(), + metadata: Some(Metadata { + description: Some("Awesome token, get it now!".to_string()), + denom_units: vec![], + base: None, + display: Some(DENOM.to_string()), + name: Some(DENOM.to_string()), + symbol: Some(DENOM.to_string()), + }), + initial_balances: vec![InitialBalance { + amount: Uint128::new(100), + mint_to_address: ADDR1.to_string(), + }], + initial_dao_balance: Some(Uint128::new(900)), + }), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let denom = get_denom(&mut app, addr.clone()); + + assert_eq!(denom.denom, format!("factory/{}/{}", addr, DENOM)); + + // Non populated fields + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + token_info: TokenInfo::New(NewTokenInfo { + subdenom: DENOM.to_string(), + metadata: Some(Metadata { + description: Some("Awesome token, get it now!".to_string()), + denom_units: vec![], + base: None, + display: Some(DENOM.to_string()), + name: Some(DENOM.to_string()), + symbol: Some(DENOM.to_string()), + }), + initial_balances: vec![InitialBalance { + amount: Uint128::new(100), + mint_to_address: ADDR1.to_string(), + }], + initial_dao_balance: None, + }), + unstaking_duration: None, + active_threshold: None, + }, + ); + + // No initial balances except DAO. + let err = instantiate_staking_error( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + token_info: TokenInfo::New(NewTokenInfo { + subdenom: DENOM.to_string(), + metadata: Some(Metadata { + description: Some("Awesome token, get it now!".to_string()), + denom_units: vec![], + base: None, + display: Some(DENOM.to_string()), + name: Some(DENOM.to_string()), + symbol: Some(DENOM.to_string()), + }), + initial_balances: vec![], + initial_dao_balance: None, + }), + unstaking_duration: None, + active_threshold: None, + }, + ); + assert_eq!(err, ContractError::InitialBalancesError {}); +} + +#[test] +fn test_instantiate_dao_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let config = get_config(&mut app, addr); + + assert_eq!(config.owner, Some(Addr::unchecked(DAO_ADDR))) +} + +#[test] +#[should_panic(expected = "Invalid unstaking duration, unstaking duration cannot be 0")] +fn test_instantiate_invalid_unstaking_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + // Populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(0)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(1), + }), + }, + ); + + // Non populated fields + let _addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: None, + manager: None, + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: None, + active_threshold: None, + }, + ); +} + +#[test] +#[should_panic(expected = "Must send reserve token 'ujuno'")] +fn test_stake_invalid_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Try and stake an invalid denom + stake_tokens(&mut app, addr, ADDR1, 100, INVALID_DENOM).unwrap(); +} + +#[test] +fn test_stake_valid_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Try and stake an valid denom + stake_tokens(&mut app, addr, ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); +} + +#[test] +fn test_stake_new_denom() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::New(NewTokenInfo { + subdenom: DENOM.to_string(), + metadata: Some(Metadata { + description: Some("Awesome token, get it now!".to_string()), + denom_units: vec![], + base: None, + display: Some(DENOM.to_string()), + name: Some(DENOM.to_string()), + symbol: Some(DENOM.to_string()), + }), + initial_balances: vec![InitialBalance { + amount: Uint128::new(100), + mint_to_address: ADDR1.to_string(), + }], + initial_dao_balance: Some(Uint128::new(900)), + }), + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Try and stake a valid denom + let denom = get_denom(&mut app, addr.clone()).denom; + stake_tokens(&mut app, addr, ADDR1, 100, &denom).unwrap(); + app.update_block(next_block); +} + +#[test] +#[should_panic(expected = "Can only unstake less than or equal to the amount you have staked")] +fn test_unstake_none_staked() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + unstake_tokens(&mut app, addr, ADDR1, 100).unwrap(); +} + +#[test] +#[should_panic(expected = "Amount being unstaked must be non-zero")] +fn test_unstake_zero_tokens() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + unstake_tokens(&mut app, addr, ADDR1, 0).unwrap(); +} + +#[test] +#[should_panic(expected = "Can only unstake less than or equal to the amount you have staked")] +fn test_unstake_invalid_balance() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Try and unstake too many + unstake_tokens(&mut app, addr, ADDR1, 200).unwrap(); +} + +#[test] +fn test_unstake() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some + unstake_tokens(&mut app, addr.clone(), ADDR1, 75).unwrap(); + + // Query claims + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 1); + app.update_block(next_block); + + // Unstake the rest + unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); + + // Query claims + let claims = get_claims(&mut app, addr, ADDR1.to_string()); + assert_eq!(claims.claims.len(), 2); +} + +#[test] +fn test_unstake_no_unstaking_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: None, + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some tokens + unstake_tokens(&mut app, addr.clone(), ADDR1, 75).unwrap(); + + app.update_block(next_block); + + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked) = 9975 + assert_eq!(balance, Uint128::new(9975)); + + // Unstake the rest + unstake_tokens(&mut app, addr, ADDR1, 25).unwrap(); + + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked 1) + 25 (unstaked 2) = 10000 + assert_eq!(balance, Uint128::new(10000)) +} + +#[test] +#[should_panic(expected = "Nothing to claim")] +fn test_claim_no_claims() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + claim(&mut app, addr, ADDR1).unwrap(); +} + +#[test] +#[should_panic(expected = "Nothing to claim")] +fn test_claim_claim_not_reached() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake them to create the claims + unstake_tokens(&mut app, addr.clone(), ADDR1, 100).unwrap(); + app.update_block(next_block); + + // We have a claim but it isnt reached yet so this will still fail + claim(&mut app, addr, ADDR1).unwrap(); +} + +#[test] +fn test_claim() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some to create the claims + unstake_tokens(&mut app, addr.clone(), ADDR1, 75).unwrap(); + app.update_block(|b| { + b.height += 5; + b.time = b.time.plus_seconds(25); + }); + + // Claim + claim(&mut app, addr.clone(), ADDR1).unwrap(); + + // Query balance + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked) = 9975 + assert_eq!(balance, Uint128::new(9975)); + + // Unstake the rest + unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); + app.update_block(|b| { + b.height += 10; + b.time = b.time.plus_seconds(50); + }); + + // Claim + claim(&mut app, addr, ADDR1).unwrap(); + + // Query balance + let balance = get_balance(&mut app, ADDR1, DENOM); + // 10000 (initial bal) - 100 (staked) + 75 (unstaked 1) + 25 (unstaked 2) = 10000 + assert_eq!(balance, Uint128::new(10000)); +} + +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_update_config_invalid_sender() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // From ADDR2, so not owner or manager + update_config( + &mut app, + addr, + ADDR2, + Some(ADDR1.to_string()), + Some(DAO_ADDR.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "Only owner can change owner")] +fn test_update_config_non_owner_changes_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // ADDR1 is the manager so cannot change the owner + update_config(&mut app, addr, ADDR1, Some(ADDR2.to_string()), None, None).unwrap(); +} + +#[test] +fn test_update_config_as_owner() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Swap owner and manager, change duration + update_config( + &mut app, + addr.clone(), + DAO_ADDR, + Some(ADDR1.to_string()), + Some(DAO_ADDR.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); + + let config = get_config(&mut app, addr); + assert_eq!( + Config { + owner: Some(Addr::unchecked(ADDR1)), + manager: Some(Addr::unchecked(DAO_ADDR)), + unstaking_duration: Some(Duration::Height(10)), + }, + config + ); +} + +#[test] +fn test_update_config_as_manager() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Change duration and manager as manager cannot change owner + update_config( + &mut app, + addr.clone(), + ADDR1, + Some(DAO_ADDR.to_string()), + Some(ADDR2.to_string()), + Some(Duration::Height(10)), + ) + .unwrap(); + + let config = get_config(&mut app, addr); + assert_eq!( + Config { + owner: Some(Addr::unchecked(DAO_ADDR)), + manager: Some(Addr::unchecked(ADDR2)), + unstaking_duration: Some(Duration::Height(10)), + }, + config + ); +} + +#[test] +#[should_panic(expected = "Invalid unstaking duration, unstaking duration cannot be 0")] +fn test_update_config_invalid_duration() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Change duration and manager as manager cannot change owner + update_config( + &mut app, + addr, + ADDR1, + Some(DAO_ADDR.to_string()), + Some(ADDR2.to_string()), + Some(Duration::Height(0)), + ) + .unwrap(); +} + +#[test] +fn test_query_dao() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let msg = QueryMsg::Dao {}; + let dao: Addr = app.wrap().query_wasm_smart(addr, &msg).unwrap(); + assert_eq!(dao, Addr::unchecked(DAO_ADDR)); +} + +#[test] +fn test_query_info() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let msg = QueryMsg::Info {}; + let resp: InfoResponse = app.wrap().query_wasm_smart(addr, &msg).unwrap(); + assert_eq!( + resp.info.contract, + "crates.io:dao-voting-token-factory-staked" + ); +} + +#[test] +fn test_query_claims() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 0); + + // Stake some tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Unstake some tokens + unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); + app.update_block(next_block); + + let claims = get_claims(&mut app, addr.clone(), ADDR1.to_string()); + assert_eq!(claims.claims.len(), 1); + + unstake_tokens(&mut app, addr.clone(), ADDR1, 25).unwrap(); + app.update_block(next_block); + + let claims = get_claims(&mut app, addr, ADDR1.to_string()); + assert_eq!(claims.claims.len(), 2); +} + +#[test] +fn test_query_get_config() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let config = get_config(&mut app, addr); + assert_eq!( + config, + Config { + owner: Some(Addr::unchecked(DAO_ADDR)), + manager: Some(Addr::unchecked(ADDR1)), + unstaking_duration: Some(Duration::Height(5)), + } + ) +} + +#[test] +fn test_voting_power_queries() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Total power is 0 + let resp = get_total_power_at_height(&mut app, addr.clone(), None); + assert!(resp.power.is_zero()); + + // ADDR1 has no power, none staked + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + assert!(resp.power.is_zero()); + + // ADDR1 stakes + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Total power is 100 + let resp = get_total_power_at_height(&mut app, addr.clone(), None); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR1 has 100 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR2 still has 0 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), None); + assert!(resp.power.is_zero()); + + // ADDR2 stakes + stake_tokens(&mut app, addr.clone(), ADDR2, 50, DENOM).unwrap(); + app.update_block(next_block); + let prev_height = app.block_info().height - 1; + + // Query the previous height, total 100, ADDR1 100, ADDR2 0 + // Total power is 100 + let resp = get_total_power_at_height(&mut app, addr.clone(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR1 has 100 power + let resp = + get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR2 still has 0 power + let resp = + get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), Some(prev_height)); + assert!(resp.power.is_zero()); + + // For current height, total 150, ADDR1 100, ADDR2 50 + // Total power is 150 + let resp = get_total_power_at_height(&mut app, addr.clone(), None); + assert_eq!(resp.power, Uint128::new(150)); + + // ADDR1 has 100 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR2 now has 50 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), None); + assert_eq!(resp.power, Uint128::new(50)); + + // ADDR1 unstakes half + unstake_tokens(&mut app, addr.clone(), ADDR1, 50).unwrap(); + app.update_block(next_block); + let prev_height = app.block_info().height - 1; + + // Query the previous height, total 150, ADDR1 100, ADDR2 50 + // Total power is 100 + let resp = get_total_power_at_height(&mut app, addr.clone(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(150)); + + // ADDR1 has 100 power + let resp = + get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR2 still has 0 power + let resp = + get_voting_power_at_height(&mut app, addr.clone(), ADDR2.to_string(), Some(prev_height)); + assert_eq!(resp.power, Uint128::new(50)); + + // For current height, total 100, ADDR1 50, ADDR2 50 + // Total power is 100 + let resp = get_total_power_at_height(&mut app, addr.clone(), None); + assert_eq!(resp.power, Uint128::new(100)); + + // ADDR1 has 50 power + let resp = get_voting_power_at_height(&mut app, addr.clone(), ADDR1.to_string(), None); + assert_eq!(resp.power, Uint128::new(50)); + + // ADDR2 now has 50 power + let resp = get_voting_power_at_height(&mut app, addr, ADDR2.to_string(), None); + assert_eq!(resp.power, Uint128::new(50)); +} + +#[test] +fn test_query_list_stakers() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::CoreModule {}), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // ADDR1 stakes + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + + // ADDR2 stakes + stake_tokens(&mut app, addr.clone(), ADDR2, 50, DENOM).unwrap(); + + // check entire result set + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::ListStakers { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + let test_res = ListStakersResponse { + stakers: vec![ + StakerBalanceResponse { + address: ADDR1.to_string(), + balance: Uint128::new(100), + }, + StakerBalanceResponse { + address: ADDR2.to_string(), + balance: Uint128::new(50), + }, + ], + }; + + assert_eq!(stakers, test_res); + + // skipped 1, check result + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr.clone(), + &QueryMsg::ListStakers { + start_after: Some(ADDR1.to_string()), + limit: None, + }, + ) + .unwrap(); + + let test_res = ListStakersResponse { + stakers: vec![StakerBalanceResponse { + address: ADDR2.to_string(), + balance: Uint128::new(50), + }], + }; + + assert_eq!(stakers, test_res); + + // skipped 2, check result. should be nothing + let stakers: ListStakersResponse = app + .wrap() + .query_wasm_smart( + addr, + &QueryMsg::ListStakers { + start_after: Some(ADDR2.to_string()), + limit: None, + }, + ) + .unwrap(); + + assert_eq!(stakers, ListStakersResponse { stakers: vec![] }); +} + +#[test] +#[should_panic(expected = "Active threshold count must be greater than zero")] +fn test_instantiate_zero_active_threshold_count() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::zero(), + }), + }, + ); +} + +#[test] +fn test_active_threshold_absolute_count() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + }, + ); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 100 tokens + stake_tokens(&mut app, addr.clone(), ADDR1, 100, DENOM).unwrap(); + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_percent() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(20), + }), + }, + ); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 6000 tokens, now active + stake_tokens(&mut app, addr.clone(), ADDR1, 6000, DENOM).unwrap(); + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_percent_rounds_up() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: ODD_DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(50), + }), + }, + ); + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 2 tokens, should not be active. + stake_tokens(&mut app, addr.clone(), ADDR1, 2, ODD_DENOM).unwrap(); + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 1 more token, should now be active. + stake_tokens(&mut app, addr.clone(), ADDR1, 1, ODD_DENOM).unwrap(); + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_none() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // Active as no threshold + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_update_active_threshold() { + let mut app = mock_app(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!(resp.active_threshold, None); + + let msg = ExecuteMsg::UpdateActiveThreshold { + new_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + }; + + // Expect failure as sender is not the DAO + app.execute_contract(Addr::unchecked(ADDR1), addr.clone(), &msg, &[]) + .unwrap_err(); + + // Expect success as sender is the DAO + app.execute_contract(Addr::unchecked(DAO_ADDR), addr.clone(), &msg, &[]) + .unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!( + resp.active_threshold, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100) + }) + ); +} + +#[test] +#[should_panic(expected = "Active threshold percentage must be greater than 0 and less than 1")] +fn test_active_threshold_percentage_gt_100() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(120), + }), + }, + ); +} + +#[test] +#[should_panic(expected = "Active threshold percentage must be greater than 0 and less than 1")] +fn test_active_threshold_percentage_lte_0() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(0), + }), + }, + ); +} + +#[test] +#[should_panic(expected = "Absolute count threshold cannot be greater than the total token supply")] +fn test_active_threshold_absolute_count_invalid() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(30001), + }), + }, + ); +} + +#[test] +fn test_add_remove_hooks() { + let mut app = App::default(); + let staking_id = app.store_code(staking_contract()); + let addr = instantiate_staking( + &mut app, + staking_id, + InstantiateMsg { + owner: Some(Admin::Address { + addr: DAO_ADDR.to_string(), + }), + manager: Some(ADDR1.to_string()), + token_info: TokenInfo::Existing { + denom: DENOM.to_string(), + }, + unstaking_duration: Some(Duration::Height(5)), + active_threshold: None, + }, + ); + + // No hooks exist. + let resp: GetHooksResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::GetHooks {}) + .unwrap(); + assert_eq!(resp.hooks, Vec::::new()); + + // Add a hook. + app.execute_contract( + Addr::unchecked(DAO_ADDR), + addr.clone(), + &ExecuteMsg::AddHook { + addr: "hook".to_string(), + }, + &[], + ) + .unwrap(); + + // One hook exists. + let resp: GetHooksResponse = app + .wrap() + .query_wasm_smart(addr.clone(), &QueryMsg::GetHooks {}) + .unwrap(); + assert_eq!(resp.hooks, vec!["hook".to_string()]); + + // Remove hook. + app.execute_contract( + Addr::unchecked(DAO_ADDR), + addr.clone(), + &ExecuteMsg::RemoveHook { + addr: "hook".to_string(), + }, + &[], + ) + .unwrap(); + + // No hook exists. + let resp: GetHooksResponse = app + .wrap() + .query_wasm_smart(addr, &QueryMsg::GetHooks {}) + .unwrap(); + assert_eq!(resp.hooks, Vec::::new()); +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: MockQuerier::default(), + custom_query_type: PhantomData::, + }; + cw2::set_contract_version(&mut deps.storage, "my-contract", "old-version").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); +} diff --git a/justfile b/justfile new file mode 100644 index 000000000..2fa8cbcfb --- /dev/null +++ b/justfile @@ -0,0 +1,66 @@ +orc_config := env_var_or_default('CONFIG', '`pwd`/ci/configs/cosm-orc/ci.yaml') +test_addrs := env_var_or_default('TEST_ADDRS', `jq -r '.[].address' ci/configs/test_accounts.json | tr '\n' ' '`) +gas_limit := env_var_or_default('GAS_LIMIT', '10000000') + +build: + cargo build + +test: + cargo test + +lint: + cargo +nightly clippy --all-targets -- -D warnings + +gen: build gen-schema + +gen-schema: + ./scripts/schema.sh + +integration-test: deploy-local workspace-optimize + RUST_LOG=info CONFIG={{orc_config}} cargo integration-test + +integration-test-dev test_name="": + SKIP_CONTRACT_STORE=true RUST_LOG=info CONFIG='{{`pwd`}}/ci/configs/cosm-orc/local.yaml' cargo integration-test {{test_name}} + +bootstrap-dev: deploy-local workspace-optimize-arm + RUST_LOG=info CONFIG={{orc_config}} cargo run bootstrap-env + +deploy-local: download-deps + docker kill cosmwasm || true + docker volume rm -f junod_data + docker run --rm -d --name cosmwasm \ + -e PASSWORD=xxxxxxxxx \ + -e STAKE_TOKEN=ujunox \ + -e GAS_LIMIT={{gas_limit}} \ + -e MAX_BYTES=22020096 \ + -e UNSAFE_CORS=true \ + -p 1317:1317 \ + -p 26656:26656 \ + -p 26657:26657 \ + -p 9090:9090 \ + --mount type=volume,source=junod_data,target=/root \ + ghcr.io/cosmoscontracts/juno:v11.0.0 /opt/setup_and_run.sh {{test_addrs}} + +download-deps: + mkdir -p artifacts target + wget https://github.com/CosmWasm/cw-plus/releases/latest/download/cw20_base.wasm -O artifacts/cw20_base.wasm + wget https://github.com/CosmWasm/cw-plus/releases/latest/download/cw4_group.wasm -O artifacts/cw4_group.wasm + wget https://github.com/CosmWasm/cw-nfts/releases/latest/download/cw721_base.wasm -O artifacts/cw721_base.wasm + +optimize: + cargo install cw-optimizoor || true + cargo cw-optimizoor Cargo.toml + +workspace-optimize: + docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + --platform linux/amd64 \ + cosmwasm/workspace-optimizer:0.12.13 + +workspace-optimize-arm: + docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + --platform linux/arm64 \ + cosmwasm/workspace-optimizer-arm64:0.12.13 diff --git a/packages/cw-denom/Cargo.toml b/packages/cw-denom/Cargo.toml new file mode 100644 index 000000000..dfb3b543d --- /dev/null +++ b/packages/cw-denom/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cw-denom" +authors = ["ekez ekez@withoutdoing.com"] +description = "A package for validation and handling of cw20 and native Cosmos SDK denominations." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } + +thiserror = { workspace = true } +cw20 = { workspace = true } + +[dev-dependencies] +cw20-base = { workspace = true } +cw-multi-test = { workspace = true } diff --git a/packages/cw-denom/README.md b/packages/cw-denom/README.md new file mode 100644 index 000000000..1ce89ac6e --- /dev/null +++ b/packages/cw-denom/README.md @@ -0,0 +1,14 @@ +# CosmWasm Denom + +This is a simple package for validating cw20 and Cosmos SDK native +denominations. It proves the types, `UncheckedDenom` and +`CheckedDenom`. `UncheckedDenom` may be used in CosmWasm contract +messages and checked via the `into_checked` method. + +To validate native denominations, this package uses the [same +rules](https://github.com/cosmos/cosmos-sdk/blob/7728516abfab950dc7a9120caad4870f1f962df5/types/coin.go#L865-L867) as the SDK. + +To validate cw20 denominations this package ensures that the +specified address is valid, that the specified address is a +CosmWasm contract, and that the specified address responds +correctly to cw20 `TokenInfo` queries. diff --git a/packages/cw-denom/src/integration_tests.rs b/packages/cw-denom/src/integration_tests.rs new file mode 100644 index 000000000..0cf73c55a --- /dev/null +++ b/packages/cw-denom/src/integration_tests.rs @@ -0,0 +1,93 @@ +use cosmwasm_std::{coins, Addr, Empty, Uint128}; +use cw20::Cw20Coin; +use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor}; + +use crate::CheckedDenom; + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +#[test] +fn test_cw20_denom_send() { + let mut app = App::default(); + + let cw20_id = app.store_code(cw20_contract()); + let cw20 = app + .instantiate_contract( + cw20_id, + Addr::unchecked("ekez"), + &cw20_base::msg::InstantiateMsg { + name: "token".to_string(), + symbol: "symbol".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + amount: Uint128::new(10), + address: "ekez".to_string(), + }], + mint: None, + marketing: None, + }, + &[], + "token contract", + None, + ) + .unwrap(); + + let denom = CheckedDenom::Cw20(cw20); + + let start_balance = denom + .query_balance(&app.wrap(), &Addr::unchecked("ekez")) + .unwrap(); + let send_message = denom + .get_transfer_to_message(&Addr::unchecked("dao"), Uint128::new(9)) + .unwrap(); + app.execute(Addr::unchecked("ekez"), send_message).unwrap(); + let end_balance = denom + .query_balance(&app.wrap(), &Addr::unchecked("ekez")) + .unwrap(); + + assert_eq!(start_balance, Uint128::new(10)); + assert_eq!(end_balance, Uint128::new(1)); + + let dao_balance = denom + .query_balance(&app.wrap(), &Addr::unchecked("dao")) + .unwrap(); + assert_eq!(dao_balance, Uint128::new(9)) +} + +#[test] +fn test_native_denom_send() { + let mut app = App::default(); + app.sudo(cw_multi_test::SudoMsg::Bank(BankSudo::Mint { + to_address: "ekez".to_string(), + amount: coins(10, "ujuno"), + })) + .unwrap(); + + let denom = CheckedDenom::Native("ujuno".to_string()); + + let start_balance = denom + .query_balance(&app.wrap(), &Addr::unchecked("ekez")) + .unwrap(); + let send_message = denom + .get_transfer_to_message(&Addr::unchecked("dao"), Uint128::new(9)) + .unwrap(); + app.execute(Addr::unchecked("ekez"), send_message).unwrap(); + let end_balance = denom + .query_balance(&app.wrap(), &Addr::unchecked("ekez")) + .unwrap(); + + assert_eq!(start_balance, Uint128::new(10)); + assert_eq!(end_balance, Uint128::new(1)); + + let dao_balance = denom + .query_balance(&app.wrap(), &Addr::unchecked("dao")) + .unwrap(); + assert_eq!(dao_balance, Uint128::new(9)) +} diff --git a/packages/cw-denom/src/lib.rs b/packages/cw-denom/src/lib.rs new file mode 100644 index 000000000..ec6a5dfc9 --- /dev/null +++ b/packages/cw-denom/src/lib.rs @@ -0,0 +1,368 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +#[cfg(test)] +mod integration_tests; + +use std::fmt::{self}; + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ + to_binary, Addr, BankMsg, Coin, CosmosMsg, CustomQuery, Deps, QuerierWrapper, StdError, + StdResult, Uint128, WasmMsg, +}; + +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum DenomError { + #[error(transparent)] + Std(#[from] StdError), + + #[error("invalid cw20 - did not respond to `TokenInfo` query: {err}")] + InvalidCw20 { err: StdError }, + + #[error("invalid native denom. length must be between in [3, 128], got ({len})")] + NativeDenomLength { len: usize }, + + #[error("expected alphabetic ascii character in native denomination")] + NonAlphabeticAscii, + + #[error("invalid character ({c}) in native denom")] + InvalidCharacter { c: char }, +} + +/// A denom that has been checked to point to a valid asset. This enum +/// should never be constructed literally and should always be built +/// by calling `into_checked` on an `UncheckedDenom` instance. +#[cw_serde] +pub enum CheckedDenom { + /// A native (bank module) asset. + Native(String), + /// A cw20 asset. + Cw20(Addr), +} + +/// A denom that has not been checked to confirm it points to a valid +/// asset. +#[cw_serde] +pub enum UncheckedDenom { + /// A native (bank module) asset. + Native(String), + /// A cw20 asset. + Cw20(String), +} + +impl UncheckedDenom { + /// Converts an unchecked denomination into a checked one. In the + /// case of native denominations, it is checked that the + /// denomination is valid according to the [default SDK rules]. In + /// the case of cw20 denominations the it is checked that the + /// specified address is valid and that that address responds to a + /// `TokenInfo` query without erroring and returns a valid + /// `cw20::TokenInfoResponse`. + /// + /// [default SDK rules]: https://github.com/cosmos/cosmos-sdk/blob/7728516abfab950dc7a9120caad4870f1f962df5/types/coin.go#L865-L867 + pub fn into_checked(self, deps: Deps) -> Result { + match self { + Self::Native(denom) => validate_native_denom(denom), + Self::Cw20(addr) => { + let addr = deps.api.addr_validate(&addr)?; + let _info: cw20::TokenInfoResponse = deps + .querier + .query_wasm_smart(addr.clone(), &cw20::Cw20QueryMsg::TokenInfo {}) + .map_err(|err| DenomError::InvalidCw20 { err })?; + Ok(CheckedDenom::Cw20(addr)) + } + } + } +} + +impl CheckedDenom { + /// Is the `CheckedDenom` this cw20? + /// + /// # Example + /// + /// ``` + /// use cosmwasm_std::{Addr, coin}; + /// use cw_denom::CheckedDenom; + /// + /// let cw20 = Addr::unchecked("fleesp"); + /// assert!(CheckedDenom::Cw20(Addr::unchecked("fleesp")).is_cw20(&cw20)); + /// assert!(!CheckedDenom::Native("fleesp".to_string()).is_cw20(&cw20)); + /// ``` + pub fn is_cw20(&self, cw20: &Addr) -> bool { + match self { + CheckedDenom::Native(_) => false, + CheckedDenom::Cw20(a) => a == cw20, + } + } + + /// Is the `CheckedDenom` this native denom? + /// + /// # Example + /// + /// ``` + /// use cosmwasm_std::{Addr, coin}; + /// use cw_denom::CheckedDenom; + /// + /// let coin = coin(10, "floob"); + /// assert!(CheckedDenom::Native("floob".to_string()).is_native(&coin.denom)); + /// assert!(!CheckedDenom::Cw20(Addr::unchecked("floob")).is_native(&coin.denom)); + /// ``` + pub fn is_native(&self, denom: &str) -> bool { + match self { + CheckedDenom::Native(n) => n == denom, + CheckedDenom::Cw20(_) => false, + } + } + + /// Queries WHO's balance for the denomination. + pub fn query_balance( + &self, + querier: &QuerierWrapper, + who: &Addr, + ) -> StdResult { + match self { + CheckedDenom::Native(denom) => Ok(querier.query_balance(who, denom)?.amount), + CheckedDenom::Cw20(address) => { + let balance: cw20::BalanceResponse = querier.query_wasm_smart( + address, + &cw20::Cw20QueryMsg::Balance { + address: who.to_string(), + }, + )?; + Ok(balance.balance) + } + } + } + + /// Gets a `CosmosMsg` that, when executed, will transfer AMOUNT + /// tokens to WHO. AMOUNT being zero will cause the message + /// execution to fail. + pub fn get_transfer_to_message(&self, who: &Addr, amount: Uint128) -> StdResult { + Ok(match self { + CheckedDenom::Native(denom) => BankMsg::Send { + to_address: who.to_string(), + amount: vec![Coin { + amount, + denom: denom.to_string(), + }], + } + .into(), + CheckedDenom::Cw20(address) => WasmMsg::Execute { + contract_addr: address.to_string(), + msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: who.to_string(), + amount, + })?, + funds: vec![], + } + .into(), + }) + } +} + +/// Follows cosmos SDK validation logic. Specifically, the regex +/// string `[a-zA-Z][a-zA-Z0-9/:._-]{2,127}`. +/// +/// +pub fn validate_native_denom(denom: String) -> Result { + if denom.len() < 3 || denom.len() > 128 { + return Err(DenomError::NativeDenomLength { len: denom.len() }); + } + let mut chars = denom.chars(); + // Really this means that a non utf-8 character is in here, but + // non-ascii is also correct. + let first = chars.next().ok_or(DenomError::NonAlphabeticAscii)?; + if !first.is_ascii_alphabetic() { + return Err(DenomError::NonAlphabeticAscii); + } + + for c in chars { + if !(c.is_ascii_alphanumeric() || c == '/' || c == ':' || c == '.' || c == '_' || c == '-') + { + return Err(DenomError::InvalidCharacter { c }); + } + } + + Ok(CheckedDenom::Native(denom)) +} + +// Useful for returning these in response objects when updating the +// config or doing a withdrawal. +impl fmt::Display for CheckedDenom { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Native(inner) => write!(f, "{inner}"), + Self::Cw20(inner) => write!(f, "{inner}"), + } + } +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::{ + testing::{mock_dependencies, MockQuerier}, + to_binary, Addr, ContractResult, QuerierResult, StdError, SystemError, Uint128, WasmQuery, + }; + + use super::*; + + const CW20_ADDR: &str = "cw20"; + + fn token_info_mock_querier(works: bool) -> impl Fn(&WasmQuery) -> QuerierResult { + move |query: &WasmQuery| -> QuerierResult { + match query { + WasmQuery::Smart { contract_addr, .. } => { + if *contract_addr == CW20_ADDR { + if works { + QuerierResult::Ok(ContractResult::Ok( + to_binary(&cw20::TokenInfoResponse { + name: "coin".to_string(), + symbol: "symbol".to_string(), + decimals: 6, + total_supply: Uint128::new(10), + }) + .unwrap(), + )) + } else { + QuerierResult::Err(SystemError::NoSuchContract { + addr: CW20_ADDR.to_string(), + }) + } + } else { + unimplemented!() + } + } + _ => unimplemented!(), + } + } + } + + #[test] + fn test_into_checked_cw20_valid() { + let mut querier = MockQuerier::default(); + querier.update_wasm(token_info_mock_querier(true)); + + let mut deps = mock_dependencies(); + deps.querier = querier; + + let unchecked = UncheckedDenom::Cw20(CW20_ADDR.to_string()); + let checked = unchecked.into_checked(deps.as_ref()).unwrap(); + + assert_eq!(checked, CheckedDenom::Cw20(Addr::unchecked(CW20_ADDR))) + } + + #[test] + fn test_into_checked_cw20_invalid() { + let mut querier = MockQuerier::default(); + querier.update_wasm(token_info_mock_querier(false)); + + let mut deps = mock_dependencies(); + deps.querier = querier; + + let unchecked = UncheckedDenom::Cw20(CW20_ADDR.to_string()); + let err = unchecked.into_checked(deps.as_ref()).unwrap_err(); + assert_eq!( + err, + DenomError::InvalidCw20 { + err: StdError::GenericErr { + msg: format!("Querier system error: No such contract: {CW20_ADDR}",) + } + } + ) + } + + #[test] + fn test_into_checked_cw20_addr_invalid() { + let mut querier = MockQuerier::default(); + querier.update_wasm(token_info_mock_querier(true)); + + let mut deps = mock_dependencies(); + deps.querier = querier; + + let unchecked = UncheckedDenom::Cw20("HasCapitalsSoShouldNotValidate".to_string()); + let err = unchecked.into_checked(deps.as_ref()).unwrap_err(); + assert_eq!( + err, + DenomError::Std(StdError::GenericErr { + msg: "Invalid input: address not normalized".to_string() + }) + ) + } + + #[test] + fn test_validate_native_denom_invalid() { + let invalids = [ + // Too short. + "ab".to_string(), + // Too long. + (0..129).map(|_| "a").collect::(), + // Starts with non alphabetic character. + "1abc".to_string(), + // Contains invalid character. + "abc~d".to_string(), + // Too short, also empty. + "".to_string(), + // Weird unicode start. + "🥵abc".to_string(), + // Weird unocide in non-head position. + "ab:12🥵a".to_string(), + // Comma is not a valid seperator. + "ab,cd".to_string(), + ]; + + for invalid in invalids { + assert!(validate_native_denom(invalid).is_err()) + } + + // Check that we're getting the errors we expect. + assert_eq!( + validate_native_denom("".to_string()), + Err(DenomError::NativeDenomLength { len: 0 }) + ); + // Should check length before contents for better runtime. + assert_eq!( + validate_native_denom("1".to_string()), + Err(DenomError::NativeDenomLength { len: 1 }) + ); + assert_eq!( + validate_native_denom("🥵abc".to_string()), + Err(DenomError::NonAlphabeticAscii) + ); + // The regex that the SDK specifies works on ASCII characters + // (not unicode classes), so this emoji has a "length" that is + // greater than one (counted in terms of ASCII characters). As + // such, we expect to fail on character validation and not + // length. + assert_eq!( + validate_native_denom("🥵".to_string()), + Err(DenomError::NonAlphabeticAscii) + ); + assert_eq!( + validate_native_denom("a🥵abc".to_string()), + Err(DenomError::InvalidCharacter { c: '🥵' }) + ); + } + + #[test] + fn test_validate_native_denom_valid() { + let valids = [ + "ujuno", + "uosmo", + "IBC/A59A9C955F1AB8B76671B00C1A0482C64A6590352944BB5880E5122358F7E1CE", + "wasm.juno123/channel-1/badkids", + ]; + for valid in valids { + validate_native_denom(valid.to_string()).unwrap(); + } + } + + #[test] + fn test_display() { + let denom = CheckedDenom::Native("hello".to_string()); + assert_eq!(denom.to_string(), "hello".to_string()); + let denom = CheckedDenom::Cw20(Addr::unchecked("hello")); + assert_eq!(denom.to_string(), "hello".to_string()); + } +} diff --git a/packages/cw-hooks/Cargo.toml b/packages/cw-hooks/Cargo.toml new file mode 100644 index 000000000..14946920a --- /dev/null +++ b/packages/cw-hooks/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cw-hooks" +authors = ["Callum Anderson "] +description = "A package for managing a set of hooks which can be accessed by their index." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +thiserror = { workspace = true } +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } diff --git a/packages/cw-hooks/README.md b/packages/cw-hooks/README.md new file mode 100644 index 000000000..d597f945c --- /dev/null +++ b/packages/cw-hooks/README.md @@ -0,0 +1,10 @@ +# CosmWasm DAO Hooks + +This package provides shared hook functionality used for +[proposal](../dao-proposal-hooks) and [vote](../dao-vote-hooks) hooks. + +It deviates from other CosmWasm hook packages in that hooks can be +modified based on their index in the hook list AND based on the +address receiving the hook. This allows dispatching hooks with their +index as the reply ID of a submessage and removing hooks if they fail +to process the hook message. diff --git a/packages/cw-hooks/src/lib.rs b/packages/cw-hooks/src/lib.rs new file mode 100644 index 000000000..fdbf43577 --- /dev/null +++ b/packages/cw-hooks/src/lib.rs @@ -0,0 +1,204 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +use thiserror::Error; + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, CustomQuery, Deps, StdError, StdResult, Storage, SubMsg}; +use cw_storage_plus::Item; + +#[cw_serde] +pub struct HooksResponse { + pub hooks: Vec, +} + +#[derive(Error, Debug, PartialEq)] +pub enum HookError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Given address already registered as a hook")] + HookAlreadyRegistered {}, + + #[error("Given address not registered as a hook")] + HookNotRegistered {}, +} + +// store all hook addresses in one item. We cannot have many of them before the contract becomes unusable anyway. +pub struct Hooks<'a>(Item<'a, Vec>); + +impl<'a> Hooks<'a> { + pub const fn new(storage_key: &'a str) -> Self { + Hooks(Item::new(storage_key)) + } + + pub fn add_hook(&self, storage: &mut dyn Storage, addr: Addr) -> Result<(), HookError> { + let mut hooks = self.0.may_load(storage)?.unwrap_or_default(); + if !hooks.iter().any(|h| h == addr) { + hooks.push(addr); + } else { + return Err(HookError::HookAlreadyRegistered {}); + } + Ok(self.0.save(storage, &hooks)?) + } + + pub fn remove_hook(&self, storage: &mut dyn Storage, addr: Addr) -> Result<(), HookError> { + let mut hooks = self.0.load(storage)?; + if let Some(p) = hooks.iter().position(|h| h == addr) { + hooks.remove(p); + } else { + return Err(HookError::HookNotRegistered {}); + } + Ok(self.0.save(storage, &hooks)?) + } + + pub fn remove_hook_by_index( + &self, + storage: &mut dyn Storage, + index: u64, + ) -> Result { + let mut hooks = self.0.load(storage)?; + let hook = hooks.remove(index as usize); + self.0.save(storage, &hooks)?; + Ok(hook) + } + + pub fn prepare_hooks StdResult>( + &self, + storage: &dyn Storage, + prep: F, + ) -> StdResult> { + self.0 + .may_load(storage)? + .unwrap_or_default() + .into_iter() + .map(prep) + .collect() + } + + pub fn prepare_hooks_custom_msg StdResult>, T>( + &self, + storage: &dyn Storage, + prep: F, + ) -> StdResult>> { + self.0 + .may_load(storage)? + .unwrap_or_default() + .into_iter() + .map(prep) + .collect::>, _>>() + } + + pub fn hook_count(&self, storage: &dyn Storage) -> StdResult { + // The WASM VM (as of version 1) is 32 bit and sets limits for + // memory accordingly: + // . We + // can safely return a u32 here as that's the biggest size in + // the WASM VM. + Ok(self.0.may_load(storage)?.unwrap_or_default().len() as u32) + } + + pub fn query_hooks(&self, deps: Deps) -> StdResult { + let hooks = self.0.may_load(deps.storage)?.unwrap_or_default(); + let hooks = hooks.into_iter().map(String::from).collect(); + Ok(HooksResponse { hooks }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::{coins, testing::mock_dependencies, BankMsg, Empty}; + + // Shorthand for an unchecked address. + macro_rules! addr { + ($x:expr ) => { + Addr::unchecked($x) + }; + } + + #[test] + fn test_hooks() { + let mut deps = mock_dependencies(); + let storage = &mut deps.storage; + let hooks = Hooks::new("hooks"); + + // Prepare hooks doesn't through error if no hooks added + let msgs = hooks + .prepare_hooks(storage, |a| { + Ok(SubMsg::reply_always( + BankMsg::Burn { + amount: coins(a.as_str().len() as u128, "uekez"), + }, + 2, + )) + }) + .unwrap(); + assert_eq!(msgs, vec![]); + + hooks.add_hook(storage, addr!("ekez")).unwrap(); + hooks.add_hook(storage, addr!("meow")).unwrap(); + + assert_eq!(hooks.hook_count(storage).unwrap(), 2); + + hooks.remove_hook_by_index(storage, 0).unwrap(); + + assert_eq!(hooks.hook_count(storage).unwrap(), 1); + + let msgs = hooks + .prepare_hooks(storage, |a| { + Ok(SubMsg::reply_always( + BankMsg::Burn { + amount: coins(a.as_str().len() as u128, "uekez"), + }, + 2, + )) + }) + .unwrap(); + + assert_eq!( + msgs, + vec![SubMsg::reply_always( + BankMsg::Burn { + amount: coins(4, "uekez"), + }, + 2, + )] + ); + + // Test prepare hooks with custom messages. + // In a real world scenario, you would be using something like + // TokenFactoryMsg. + let msgs = hooks + .prepare_hooks_custom_msg(storage, |a| { + Ok(SubMsg::::reply_always( + BankMsg::Burn { + amount: coins(a.as_str().len() as u128, "uekez"), + }, + 2, + )) + }) + .unwrap(); + + assert_eq!( + msgs, + vec![SubMsg::::reply_always( + BankMsg::Burn { + amount: coins(4, "uekez"), + }, + 2, + )] + ); + + // Query hooks returns all hooks added + let HooksResponse { hooks: the_hooks } = hooks.query_hooks(deps.as_ref()).unwrap(); + assert_eq!(the_hooks, vec![addr!("meow")]); + + // Remove last hook + hooks.remove_hook(&mut deps.storage, addr!("meow")).unwrap(); + + // Query hooks returns empty vector if no hooks added + let HooksResponse { hooks: the_hooks } = hooks.query_hooks(deps.as_ref()).unwrap(); + let no_hooks: Vec = vec![]; + assert_eq!(the_hooks, no_hooks); + } +} diff --git a/packages/cw-paginate-storage/Cargo.toml b/packages/cw-paginate-storage/Cargo.toml new file mode 100644 index 000000000..dbe83025a --- /dev/null +++ b/packages/cw-paginate-storage/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cw-paginate-storage" +authors = ["ekez ekez@withoutdoing.com"] +description = "A package for paginating cosmwasm maps." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/packages/cw-paginate-storage/README.md b/packages/cw-paginate-storage/README.md new file mode 100644 index 000000000..03a9d03eb --- /dev/null +++ b/packages/cw-paginate-storage/README.md @@ -0,0 +1,40 @@ +# CosmWasm Map Pagination + +This package provides generic convienence methods for paginating keys +and values in a CosmWasm `Map` or `SnapshotMap`. If you use these +methods to paginate the maps in your contract you may [make larry0x +happy](https://twitter.com/larry0x/status/1530537243709939719). + +## Example + +Given a map like: + +```rust +use cw_storage_plus::Map; + +pub const ITEMS: Map = Map::new("items"); +``` + +You can use this package to write a query to list it's contents like: + +```rust +use cosmwasm_std::{Deps, Binary, to_binary, StdResult}; +use cw_storage_plus::Map; +use cw_paginate_storage::paginate_map; + +pub const ITEMS: Map = Map::new("items"); + +pub fn query_list_items( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + to_binary(&paginate_map( + deps, + &ITEMS, + start_after, + limit, + cosmwasm_std::Order::Descending, + )?) +} +``` diff --git a/packages/cw-paginate-storage/src/lib.rs b/packages/cw-paginate-storage/src/lib.rs new file mode 100644 index 000000000..66a0d046c --- /dev/null +++ b/packages/cw-paginate-storage/src/lib.rs @@ -0,0 +1,537 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +use cosmwasm_std::{Deps, Order, StdResult}; + +#[allow(unused_imports)] +use cw_storage_plus::{Bound, Bounder, KeyDeserialize, Map, SnapshotMap, Strategy}; + +/// Generic function for paginating a list of (K, V) pairs in a +/// CosmWasm Map. +pub fn paginate_map<'a, 'b, K, V, R: 'static>( + deps: Deps, + map: &Map<'a, K, V>, + start_after: Option, + limit: Option, + order: Order, +) -> StdResult> +where + K: Bounder<'a> + KeyDeserialize + 'b, + V: serde::de::DeserializeOwned + serde::Serialize, +{ + let (range_min, range_max) = match order { + Order::Ascending => (start_after.map(Bound::exclusive), None), + Order::Descending => (None, start_after.map(Bound::exclusive)), + }; + + let items = map.range(deps.storage, range_min, range_max, order); + match limit { + Some(limit) => Ok(items + .take(limit.try_into().unwrap()) + .collect::>()?), + None => Ok(items.collect::>()?), + } +} + +/// Same as `paginate_map` but only returns the keys. +pub fn paginate_map_keys<'a, 'b, K, V, R: 'static>( + deps: Deps, + map: &Map<'a, K, V>, + start_after: Option, + limit: Option, + order: Order, +) -> StdResult> +where + K: Bounder<'a> + KeyDeserialize + 'b, + V: serde::de::DeserializeOwned + serde::Serialize, +{ + let (range_min, range_max) = match order { + Order::Ascending => (start_after.map(Bound::exclusive), None), + Order::Descending => (None, start_after.map(Bound::exclusive)), + }; + + let items = map.keys(deps.storage, range_min, range_max, order); + match limit { + Some(limit) => Ok(items + .take(limit.try_into().unwrap()) + .collect::>()?), + None => Ok(items.collect::>()?), + } +} + +/// Same as `paginate_map` but for use with `SnapshotMap`. +pub fn paginate_snapshot_map<'a, 'b, K, V, R: 'static>( + deps: Deps, + map: &SnapshotMap<'a, K, V>, + start_after: Option, + limit: Option, + order: Order, +) -> StdResult> +where + K: Bounder<'a> + KeyDeserialize + 'b, + V: serde::de::DeserializeOwned + serde::Serialize, +{ + let (range_min, range_max) = match order { + Order::Ascending => (start_after.map(Bound::exclusive), None), + Order::Descending => (None, start_after.map(Bound::exclusive)), + }; + + let items = map.range(deps.storage, range_min, range_max, order); + match limit { + Some(limit) => Ok(items + .take(limit.try_into().unwrap()) + .collect::>()?), + None => Ok(items.collect::>()?), + } +} + +/// Same as `paginate_map` but only returns the values. +pub fn paginate_map_values<'a, K, V>( + deps: Deps, + map: &Map<'a, K, V>, + start_after: Option, + limit: Option, + order: Order, +) -> StdResult> +where + K: Bounder<'a> + KeyDeserialize + 'static, + V: serde::de::DeserializeOwned + serde::Serialize, +{ + let (range_min, range_max) = match order { + Order::Ascending => (start_after.map(Bound::exclusive), None), + Order::Descending => (None, start_after.map(Bound::exclusive)), + }; + + let items = map + .range(deps.storage, range_min, range_max, order) + .map(|kv| Ok(kv?.1)); + + match limit { + Some(limit) => Ok(items + .take(limit.try_into().unwrap()) + .collect::>()?), + None => Ok(items.collect::>()?), + } +} + +/// Same as `paginate_map` but only returns the keys. For use with +/// `SnaphotMap`. +pub fn paginate_snapshot_map_keys<'a, 'b, K, V, R: 'static>( + deps: Deps, + map: &SnapshotMap<'a, K, V>, + start_after: Option, + limit: Option, + order: Order, +) -> StdResult> +where + K: Bounder<'a> + KeyDeserialize + 'b, + V: serde::de::DeserializeOwned + serde::Serialize, +{ + let (range_min, range_max) = match order { + Order::Ascending => (start_after.map(Bound::exclusive), None), + Order::Descending => (None, start_after.map(Bound::exclusive)), + }; + + let items = map.keys(deps.storage, range_min, range_max, order); + match limit { + Some(limit) => Ok(items + .take(limit.try_into().unwrap()) + .collect::>()?), + None => Ok(items.collect::>()?), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{mock_dependencies, mock_env}; + use cosmwasm_std::{Addr, Uint128}; + + #[test] + fn pagination() { + let mut deps = mock_dependencies(); + let map: Map = Map::new("items"); + + for num in 1..3 { + map.save(&mut deps.storage, num.to_string(), &(num * 2).to_string()) + .unwrap(); + } + + let items = paginate_map(deps.as_ref(), &map, None, None, Order::Descending).unwrap(); + assert_eq!( + items, + vec![ + ("2".to_string(), "4".to_string()), + ("1".to_string(), "2".to_string()) + ] + ); + + let items = paginate_map(deps.as_ref(), &map, None, None, Order::Ascending).unwrap(); + assert_eq!( + items, + vec![ + ("1".to_string(), "2".to_string()), + ("2".to_string(), "4".to_string()) + ] + ); + + let items = paginate_map( + deps.as_ref(), + &map, + Some("1".to_string()), + None, + Order::Ascending, + ) + .unwrap(); + assert_eq!(items, vec![("2".to_string(), "4".to_string())]); + + let items = paginate_map(deps.as_ref(), &map, None, Some(1), Order::Ascending).unwrap(); + assert_eq!(items, vec![("1".to_string(), "2".to_string())]); + } + + #[test] + fn key_pagination() { + let mut deps = mock_dependencies(); + let map: Map = Map::new("items"); + + for num in 1..3 { + map.save(&mut deps.storage, num.to_string(), &(num * 2).to_string()) + .unwrap(); + } + + let items = paginate_map_keys(deps.as_ref(), &map, None, None, Order::Descending).unwrap(); + assert_eq!(items, vec!["2".to_string(), "1".to_string()]); + + let items = paginate_map_keys(deps.as_ref(), &map, None, None, Order::Ascending).unwrap(); + assert_eq!(items, vec!["1".to_string(), "2".to_string()]); + + let items = paginate_map_keys( + deps.as_ref(), + &map, + Some("1".to_string()), + None, + Order::Ascending, + ) + .unwrap(); + assert_eq!(items, vec!["2"]); + + let items = + paginate_map_keys(deps.as_ref(), &map, None, Some(1), Order::Ascending).unwrap(); + assert_eq!(items, vec!["1".to_string()]); + } + + // this test will double check the descending keys with the rewrite + #[test] + fn key_pagination_test2() { + let mut deps = mock_dependencies(); + let map: Map = Map::new("items"); + + for num in 1u32..=10 { + map.save(&mut deps.storage, num, &(num * 2).to_string()) + .unwrap(); + } + + let items = paginate_map_keys(deps.as_ref(), &map, None, None, Order::Descending).unwrap(); + assert_eq!(items, vec![10, 9, 8, 7, 6, 5, 4, 3, 2, 1]); + + let items = paginate_map_keys(deps.as_ref(), &map, None, None, Order::Ascending).unwrap(); + assert_eq!(items, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + let items = + paginate_map_keys(deps.as_ref(), &map, Some(3), Some(3), Order::Ascending).unwrap(); + assert_eq!(items, vec![4, 5, 6]); + + let items = + paginate_map_keys(deps.as_ref(), &map, Some(7), Some(4), Order::Descending).unwrap(); + assert_eq!(items, vec![6, 5, 4, 3]); + } + + #[test] + fn snapshot_pagination() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + let map: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "items", + "items__checkpoints", + "items__changelog", + Strategy::EveryBlock, + ); + + for ctr in 1..100 { + let addr = Addr::unchecked(format!("test_addr{:0>3}", ctr.clone())); + map.save( + &mut deps.storage, + &addr, + &Uint128::new(ctr), + env.block.height, + ) + .unwrap(); + } + + // grab first 10 items + let items = + paginate_snapshot_map(deps.as_ref(), &map, None, Some(10), Order::Ascending).unwrap(); + + assert_eq!(items.len(), 10); + + let mut test_vec: Vec<(Addr, Uint128)> = vec![]; + for ctr in 1..=10 { + let addr = Addr::unchecked(format!("test_addr{:0>3}", ctr.clone())); + + test_vec.push((addr, Uint128::new(ctr))); + } + assert_eq!(items, test_vec); + + // using the last result of the last item (10), grab the next one + let items = paginate_snapshot_map( + deps.as_ref(), + &map, + Some(&items[items.len() - 1].0), + Some(10), + Order::Ascending, + ) + .unwrap(); + + // should be the 11th item + assert_eq!(items[0].0, Addr::unchecked("test_addr011".to_string())); + assert_eq!(items[0].1, Uint128::new(11)); + + let items = + paginate_snapshot_map(deps.as_ref(), &map, None, None, Order::Descending).unwrap(); + + // 20th item (19 index) should be 80 + assert_eq!(items[19].0, Addr::unchecked("test_addr080".to_string())); + assert_eq!(items[19].1, Uint128::new(80)); + } + + // this test will encapsulate the generic changes for &Addr + #[test] + fn snapshot_pagination_keys_new_generic() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + let map: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "items", + "items__checkpoints", + "items__changelog", + Strategy::EveryBlock, + ); + + for ctr in 1..100 { + let addr = Addr::unchecked(format!("test_addr{:0>3}", ctr.clone())); + map.save( + &mut deps.storage, + &addr, + &Uint128::new(ctr), + env.block.height, + ) + .unwrap(); + } + + // grab first 10 items + let items = + paginate_snapshot_map_keys(deps.as_ref(), &map, None, Some(10), Order::Ascending) + .unwrap(); + + assert_eq!(items.len(), 10); + + let mut test_vec: Vec = vec![]; + for ctr in 1..=10 { + let addr = Addr::unchecked(format!("test_addr{:0>3}", ctr.clone())); + + test_vec.push(addr); + } + assert_eq!(items, test_vec); + + // max item from before was the 10th, so it'll go backwards from 9->1 + let items = paginate_snapshot_map_keys( + deps.as_ref(), + &map, + Some(&items[items.len() - 1]), + None, + Order::Descending, + ) + .unwrap(); + + // 3rd item in vec should be 006 + assert_eq!(items[3], Addr::unchecked("test_addr006".to_string())); + + let items = + paginate_snapshot_map_keys(deps.as_ref(), &map, None, None, Order::Descending).unwrap(); + + // 20th item (19 index) should be 80 + assert_eq!(items[19], Addr::unchecked("test_addr080".to_string())); + } + + #[test] + fn snapshot_pagination_keys() { + let mut deps = mock_dependencies(); + let env = mock_env(); + + let map: SnapshotMap = SnapshotMap::new( + "items", + "items__checkpoints", + "items__changelog", + Strategy::EveryBlock, + ); + + for ctr in 1..=100 { + map.save( + &mut deps.storage, + ctr, + &Uint128::new(ctr.try_into().unwrap()), + env.block.height, + ) + .unwrap(); + } + + // grab first 10 items + let items = + paginate_snapshot_map_keys(deps.as_ref(), &map, None, Some(10), Order::Ascending) + .unwrap(); + + assert_eq!(items.len(), 10); + assert_eq!(items, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + let items = + paginate_snapshot_map_keys(deps.as_ref(), &map, Some(50), Some(10), Order::Ascending) + .unwrap(); + + assert_eq!(items, vec![51, 52, 53, 54, 55, 56, 57, 58, 59, 60]); + + let items = + paginate_snapshot_map_keys(deps.as_ref(), &map, Some(50), Some(10), Order::Descending) + .unwrap(); + + assert_eq!(items, vec![49, 48, 47, 46, 45, 44, 43, 42, 41, 40]); + } + + #[test] + fn pagination_order_desc_tests() { + let mut deps = mock_dependencies(); + let map: Map = Map::new("items"); + + map.save(&mut deps.storage, 1, &40).unwrap(); + map.save(&mut deps.storage, 2, &22).unwrap(); + map.save(&mut deps.storage, 3, &77).unwrap(); + map.save(&mut deps.storage, 4, &66).unwrap(); + map.save(&mut deps.storage, 5, &0).unwrap(); + + let items = paginate_map(deps.as_ref(), &map, None, None, Order::Descending).unwrap(); + assert_eq!(items, vec![(5, 0), (4, 66), (3, 77), (2, 22), (1, 40)]); + + let items = paginate_map(deps.as_ref(), &map, Some(3), None, Order::Descending).unwrap(); + assert_eq!(items, vec![(2, 22), (1, 40)]); + + let items = paginate_map(deps.as_ref(), &map, Some(1), None, Order::Descending).unwrap(); + assert_eq!(items, vec![]); + } + + /// testing reworked paginate_map and paginate_map_keys. + /// pay particular attention to the values added. this is to ensure + /// that the values arent being assessed + #[test] + fn pagination_keys_refs() { + let mut deps = mock_dependencies(); + let map: Map<&Addr, u32> = Map::new("items"); + + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 1)), + &40, + ) + .unwrap(); + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 2)), + &22, + ) + .unwrap(); + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 3)), + &77, + ) + .unwrap(); + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 4)), + &66, + ) + .unwrap(); + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 5)), + &0, + ) + .unwrap(); + + let items = paginate_map_keys(deps.as_ref(), &map, None, None, Order::Descending).unwrap(); + assert_eq!(items[1], Addr::unchecked(format!("test_addr{:0>3}", 4))); + assert_eq!(items[4], Addr::unchecked(format!("test_addr{:0>3}", 1))); + + let addr: Addr = Addr::unchecked(format!("test_addr{:0>3}", 3)); + let items = + paginate_map_keys(deps.as_ref(), &map, Some(&addr), None, Order::Ascending).unwrap(); + assert_eq!(items[0], Addr::unchecked(format!("test_addr{:0>3}", 4))); + } + + /// testing reworked paginate_map and paginate_map_keys. + /// pay particular attention to the values added. this is to ensure + /// that the values arent being assessed + #[test] + fn pagination_refs() { + let mut deps = mock_dependencies(); + let map: Map<&Addr, u32> = Map::new("items"); + + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 1)), + &0, + ) + .unwrap(); + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 2)), + &22, + ) + .unwrap(); + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 3)), + &77, + ) + .unwrap(); + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 4)), + &66, + ) + .unwrap(); + map.save( + &mut deps.storage, + &Addr::unchecked(format!("test_addr{:0>3}", 6)), + &0, + ) + .unwrap(); + + let items = paginate_map(deps.as_ref(), &map, None, None, Order::Descending).unwrap(); + assert_eq!( + items[1], + (Addr::unchecked(format!("test_addr{:0>3}", 4)), 66) + ); + assert_eq!( + items[4], + (Addr::unchecked(format!("test_addr{:0>3}", 1)), 0) + ); + + let addr: Addr = Addr::unchecked(format!("test_addr{:0>3}", 3)); + let items = + paginate_map(deps.as_ref(), &map, Some(&addr), Some(2), Order::Ascending).unwrap(); + let test_vec: Vec<(Addr, u32)> = vec![ + (Addr::unchecked(format!("test_addr{:0>3}", 4)), 66), + (Addr::unchecked(format!("test_addr{:0>3}", 6)), 0), + ]; + assert_eq!(items, test_vec); + } +} diff --git a/packages/cw-stake-tracker/Cargo.toml b/packages/cw-stake-tracker/Cargo.toml new file mode 100644 index 000000000..cb813d58d --- /dev/null +++ b/packages/cw-stake-tracker/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cw-stake-tracker" +authors = ["ekez "] +description = "A package for tracking staked and unbonding tokens in x/staking." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-wormhole = { workspace = true } diff --git a/packages/cw-stake-tracker/README.md b/packages/cw-stake-tracker/README.md new file mode 100644 index 000000000..af431edb9 --- /dev/null +++ b/packages/cw-stake-tracker/README.md @@ -0,0 +1,17 @@ +# Stake Tracker + +This is a CosmWasm package for tracking the staked balance of a smart +contract. + +The `StakeTracker` type here exposes a couple methods with the `on_` +prefix. These should be called whenever the contract performs an +action with x/staking. For example, when the contract delegates +tokens, it should call the `on_delegate` method to register that. Not +calling the method will cause the package to incorrectly track staked +values. + +See +[`cw-vesting`](https://github.com/DA0-DA0/dao-contracts/blob/main/contracts/external/cw-vesting/SECURITY.md#slashing) +for an example of integrating this package into a smart contract and a +discussion of how to handle slash events. + diff --git a/packages/cw-stake-tracker/src/lib.rs b/packages/cw-stake-tracker/src/lib.rs new file mode 100644 index 000000000..fc9a774cf --- /dev/null +++ b/packages/cw-stake-tracker/src/lib.rs @@ -0,0 +1,273 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{to_binary, Binary, StdResult, Storage, Timestamp, Uint128}; +use cw_wormhole::Wormhole; + +#[cfg(test)] +mod tests; + +pub struct StakeTracker<'a> { + /// staked(t) := the total number of native tokens staked & + /// unbonding with validators at time t. + total_staked: Wormhole<'a, (), Uint128>, + /// validators(v, t) := the amount staked + amount unbonding with + /// validator v at time t. + /// + /// deps.api.addr_validate does not validate validator addresses, + /// so we're left with a string. in theory, as all of these + /// functions are called only _on_ (un)delegation, their + /// surrounding transactions should fail for invalid keys as the + /// staking module ought to error. this is checked in + /// `test_cw_vesting_staking` in + /// `ci/integration-tests/src/tests/cw_vesting_test.rs`. + validators: Wormhole<'a, String, Uint128>, + /// cardinality(t) := the # of validators with staked and/or + /// unbonding tokens at time t. + cardinality: Wormhole<'a, (), u64>, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum StakeTrackerQuery { + #[returns(::cosmwasm_std::Uint128)] + Cardinality { t: Timestamp }, + #[returns(::cosmwasm_std::Uint128)] + TotalStaked { t: Timestamp }, + #[returns(::cosmwasm_std::Uint128)] + ValidatorStaked { validator: String, t: Timestamp }, +} + +impl<'a> StakeTracker<'a> { + pub const fn new( + staked_prefix: &'a str, + validator_prefix: &'a str, + cardinality_prefix: &'a str, + ) -> Self { + Self { + total_staked: Wormhole::new(staked_prefix), + validators: Wormhole::new(validator_prefix), + cardinality: Wormhole::new(cardinality_prefix), + } + } + + pub fn on_delegate( + &self, + storage: &mut dyn Storage, + t: Timestamp, + validator: String, + amount: Uint128, + ) -> StdResult<()> { + self.total_staked + .increment(storage, (), t.seconds(), amount)?; + let old = self + .validators + .load(storage, validator.clone(), t.seconds())? + .unwrap_or_default(); + if old.is_zero() && !amount.is_zero() { + self.cardinality.increment(storage, (), t.seconds(), 1)?; + } + self.validators + .increment(storage, validator, t.seconds(), amount)?; + Ok(()) + } + + /// Makes note of a redelegation. Note, this only supports + /// redelegation of tokens that can be _immediately_ + /// redelegated. The caller of this function should make a + /// `Delegation { delegator, validator: src }` query and ensure + /// that `amount <= resp.can_redelegate`. + pub fn on_redelegate( + &self, + storage: &mut dyn Storage, + t: Timestamp, + src: String, + dst: String, + amount: Uint128, + ) -> StdResult<()> { + let new = self + .validators + .decrement(storage, src, t.seconds(), amount)?; + if new.is_zero() { + self.cardinality.decrement(storage, (), t.seconds(), 1)?; + } + let new = self + .validators + .increment(storage, dst, t.seconds(), amount)?; + if new == amount { + self.cardinality.increment(storage, (), t.seconds(), 1)?; + } + Ok(()) + } + + pub fn on_undelegate( + &self, + storage: &mut dyn Storage, + t: Timestamp, + validator: String, + amount: Uint128, + unbonding_duration_seconds: u64, + ) -> StdResult<()> { + self.total_staked.decrement( + storage, + (), + t.seconds() + unbonding_duration_seconds, + amount, + )?; + let new = self.validators.decrement( + storage, + validator, + t.seconds() + unbonding_duration_seconds, + amount, + )?; + if new.is_zero() && !amount.is_zero() { + self.cardinality + .decrement(storage, (), t.seconds() + unbonding_duration_seconds, 1)?; + } + Ok(()) + } + + /// Registers a slash of bonded tokens. + /// + /// Invariants: + /// 1. amount is non-zero. + /// 2. the slash did indeed occur. + /// + /// Checking that these invariants are true is the responsibility + /// of the caller. + pub fn on_bonded_slash( + &self, + storage: &mut dyn Storage, + t: Timestamp, + validator: String, + amount: Uint128, + ) -> StdResult<()> { + enum Change { + /// Increment by one at (time: u64). + Inc(u64), + /// Decrement by one at (time: u64). + Dec(u64), + } + + self.total_staked + .decrement(storage, (), t.seconds(), amount)?; + + // tracks if the last value was non-zero after removing the + // slash amount. invariant (2) lets us initialize this to true + // as staked tokens are a prerequisite for slashing. + let mut was_nonzero = true; + // the set of times that the cardinality would have changed + // had the slash event been known. + let mut cardinality_changes = vec![]; + + // visit the history, update values to include the slashed + // amount, and make note of the changes to the cardinality + // history needed. + self.validators + .update(storage, validator, t.seconds(), &mut |staked, time| { + let new = staked - amount; + if new.is_zero() && was_nonzero { + // the slash would have removed all staked tokens + // at `time` => decrement the cardinality at `time`. + cardinality_changes.push(Change::Dec(time)); + was_nonzero = false; + } else if !new.is_zero() && !was_nonzero { + // the staked amount (including the slash) was + // zero, and more tokens were staked, increment + // the cardinality. + cardinality_changes.push(Change::Inc(time)); + was_nonzero = true; + } + new + })?; + + // we can't do these updates as part of the `update` call + // above as that would require two mutable references to + // storage. + for change in cardinality_changes { + match change { + Change::Inc(time) => self.cardinality.increment(storage, (), time, 1)?, + Change::Dec(time) => self.cardinality.decrement(storage, (), time, 1)?, + }; + } + + Ok(()) + } + + /// Registers a slash of unbonding tokens. + /// + /// Invariants: + /// 1. amount is non-zero. + /// 2. the slash did indeed occur. + /// + /// Checking that these invariants are true is the responsibility + /// of the caller. + pub fn on_unbonding_slash( + &self, + storage: &mut dyn Storage, + t: Timestamp, + validator: String, + amount: Uint128, + ) -> StdResult<()> { + // invariant (2) provides that a slash did occur at time `t`, + // and that the `amount` <= `total_unbonding`. As such, we + // know at some time `t' > t`, total_staked, and + // validator_staked are scheduled to decrease by an amount >= + // `amount`. this means that we can safely use + // `dangerously_update` as we are only adding an intermediate + // step to reach a future value (`staked - total_unbonding`). + + self.total_staked + .dangerously_update(storage, (), t.seconds(), &mut |v, _| v - amount)?; + let new = + self.validators + .dangerously_update(storage, validator, t.seconds(), &mut |v, _| v - amount)?; + if new.is_zero() { + self.cardinality + .dangerously_update(storage, (), t.seconds(), &mut |v, _| v - 1)?; + } + Ok(()) + } + + /// Gets the total number of bonded and unbonding tokens across + /// all validators. + pub fn total_staked(&self, storage: &dyn Storage, t: Timestamp) -> StdResult { + self.total_staked + .load(storage, (), t.seconds()) + .map(|v| v.unwrap_or_default()) + } + + /// Gets gets the number of tokens in the bonded or unbonding + /// state for validator `v`. + pub fn validator_staked( + &self, + storage: &dyn Storage, + t: Timestamp, + v: String, + ) -> StdResult { + self.validators + .load(storage, v, t.seconds()) + .map(|v| v.unwrap_or_default()) + } + + /// Gets the number of validators for which there is a non-zero + /// number of tokens in the bonding or unbonding state for. + pub fn validator_cardinality(&self, storage: &dyn Storage, t: Timestamp) -> StdResult { + self.cardinality + .load(storage, (), t.seconds()) + .map(|v| v.unwrap_or_default()) + } + + /// Provides a query interface for contracts that embed this stake + /// tracker and want to make its information part of their public + /// API. + pub fn query(&self, storage: &dyn Storage, msg: StakeTrackerQuery) -> StdResult { + match msg { + StakeTrackerQuery::Cardinality { t } => to_binary(&Uint128::new( + self.validator_cardinality(storage, t)?.into(), + )), + StakeTrackerQuery::TotalStaked { t } => to_binary(&self.total_staked(storage, t)?), + StakeTrackerQuery::ValidatorStaked { validator, t } => { + to_binary(&self.validator_staked(storage, t, validator)?) + } + } + } +} diff --git a/packages/cw-stake-tracker/src/tests.rs b/packages/cw-stake-tracker/src/tests.rs new file mode 100644 index 000000000..3d0c46b37 --- /dev/null +++ b/packages/cw-stake-tracker/src/tests.rs @@ -0,0 +1,483 @@ +use cosmwasm_std::{from_binary, testing::mock_dependencies, Timestamp, Uint128}; + +use crate::{StakeTracker, StakeTrackerQuery}; + +#[test] +fn test_stake_tracking() { + let storage = &mut mock_dependencies().storage; + + let st = StakeTracker::new("s", "v", "c"); + let mut time = Timestamp::from_seconds(0); + let unbonding_duration_seconds = 100; + + // cardinality, total, and validator_staked start at 0. + assert_eq!(st.validator_cardinality(storage, time).unwrap(), 0); + assert_eq!(st.total_staked(storage, time).unwrap(), Uint128::zero()); + assert_eq!( + st.validator_staked(storage, time, "v1".to_string()) + .unwrap(), + Uint128::zero() + ); + + // delegating increases validator cardinality, validator_staked, and total. + st.on_delegate(storage, time, "v1".to_string(), Uint128::new(10)) + .unwrap(); + + assert_eq!(st.validator_cardinality(storage, time).unwrap(), 1); + assert_eq!(st.total_staked(storage, time).unwrap(), Uint128::new(10)); + assert_eq!( + st.validator_staked(storage, time, "v1".to_string()) + .unwrap(), + Uint128::new(10) + ); + // delegating to one validator does not change the status of other validators. + assert_eq!( + st.validator_staked(storage, time, "v2".to_string()) + .unwrap(), + Uint128::zero() + ); + + // delegate to another validator, and undelegate from the first + // one. the undelegation should not change cardinality or staked + // values until the unbonding duration has passed. + st.on_delegate(storage, time, "v2".to_string(), Uint128::new(10)) + .unwrap(); + st.on_undelegate( + storage, + time, + "v1".to_string(), + Uint128::new(10), + unbonding_duration_seconds, + ) + .unwrap(); + + assert_eq!(st.validator_cardinality(storage, time).unwrap(), 2); + assert_eq!(st.total_staked(storage, time).unwrap(), Uint128::new(20)); + assert_eq!( + st.validator_staked(storage, time, "v1".to_string()) + .unwrap(), + Uint128::new(10) + ); + assert_eq!( + st.validator_staked(storage, time, "v2".to_string()) + .unwrap(), + Uint128::new(10) + ); + + // after unbonding duration passes, undelegation changes should be + // visible. + time = time.plus_seconds(unbonding_duration_seconds); + + assert_eq!(st.validator_cardinality(storage, time).unwrap(), 1); + assert_eq!(st.total_staked(storage, time).unwrap(), Uint128::new(10)); + assert_eq!( + st.validator_staked(storage, time, "v1".to_string()) + .unwrap(), + Uint128::zero() + ); + assert_eq!( + st.validator_staked(storage, time, "v2".to_string()) + .unwrap(), + Uint128::new(10) + ); +} + +#[test] +#[should_panic(expected = "attempt to subtract with overflow")] +fn test_undelegation_before_delegation_panics() { + let storage = &mut mock_dependencies().storage; + + let st = StakeTracker::new("s", "v", "c"); + + st.on_delegate( + storage, + Timestamp::default(), + "v2".to_string(), + Uint128::new(10), + ) + .unwrap(); + + // there are 10 staked tokens total, but they are not staked to + // this validator so removing them should cause an error. + st.on_undelegate( + storage, + Timestamp::default(), + "v1".to_string(), + Uint128::new(10), + 10, + ) + .unwrap(); +} + +#[test] +fn test_bonded_slash() { + let storage = &mut mock_dependencies().storage; + let st = StakeTracker::new("s", "v", "c"); + + st.on_delegate( + storage, + Timestamp::from_seconds(10), + "v1".to_string(), + Uint128::new(10), + ) + .unwrap(); + + // undelegate half of tokens at t=10. + st.on_undelegate( + storage, + Timestamp::from_seconds(10), + "v1".to_string(), + Uint128::new(5), + 5, + ) + .unwrap(); + + // slash the rest at t=12. + st.on_bonded_slash( + storage, + Timestamp::from_seconds(12), + "v1".to_string(), + Uint128::new(5), + ) + .unwrap(); + + // at t=13 tokens are still "staked" as this tracks `bonded + + // unbonding`. + assert_eq!( + st.validator_cardinality(storage, Timestamp::from_seconds(13)) + .unwrap(), + 1 + ); + // at t=15 the unbonding has completed and there are no tokens + // staked. `on_bonded_slash` ought to have updated the + // cardinality. + assert_eq!( + st.validator_cardinality(storage, Timestamp::from_seconds(15)) + .unwrap(), + 0 + ); + + // at time t=10, there are five bonded tokens and five unbonding + // tokens so 10 total staked. + let staked = st + .validator_staked(storage, Timestamp::from_seconds(10), "v1".to_string()) + .unwrap(); + assert_eq!(staked, Uint128::new(10)); + + // at time t=12 all of the bonded tokens have been slashed, but + // the unbonding ones are still unbonding. + let staked = st + .validator_staked(storage, Timestamp::from_seconds(12), "v1".to_string()) + .unwrap(); + assert_eq!(staked, Uint128::new(5)); + + // at time t=15 all of the unbonding has completed and there are + // no staked tokens. + let staked = st + .validator_staked(storage, Timestamp::from_seconds(15), "v1".to_string()) + .unwrap(); + assert_eq!(staked, Uint128::zero()); +} + +/// t=0 -> bond 10 tokens +/// t=1 -> five tokens slashed, not registered +/// t=2 -> unbond five tokens w/ five second unbonding period +/// t=7 -> cardinality=0 w/ slash considered +/// t=8 -> bond five tokens +/// t=9 -> unbond five tokenw w/ five second unbonding period +/// +/// t=9 -> register slash at time t=1 +/// t=9 -> cardinality history should now reflect reality. +#[test] +fn test_bonded_slash_updates_cardinality_history() { + let storage = &mut mock_dependencies().storage; + let st = StakeTracker::new("s", "v", "c"); + + st.on_delegate( + storage, + Timestamp::from_seconds(0), + "v1".to_string(), + Uint128::new(10), + ) + .unwrap(); + // t=1 slash of five tokens occurs. + st.on_undelegate( + storage, + Timestamp::from_seconds(2), + "v1".to_string(), + Uint128::new(5), + 5, + ) + .unwrap(); + + st.on_delegate( + storage, + Timestamp::from_seconds(8), + "v1".to_string(), + Uint128::new(5), + ) + .unwrap(); + + // t=7, cardinality=0. but slash not registered so system thinks + // the cardinality is 1. + assert_eq!( + st.validator_cardinality(storage, Timestamp::from_seconds(7)) + .unwrap(), + 1 + ); + + // register the slash + st.on_bonded_slash( + storage, + Timestamp::from_seconds(1), + "v1".to_string(), + Uint128::new(5), + ) + .unwrap(); + + // t=0, cardinality=1 + assert_eq!( + st.validator_cardinality(storage, Timestamp::from_seconds(0)) + .unwrap(), + 1 + ); + // t=1, cardinality=1 + assert_eq!( + st.validator_cardinality(storage, Timestamp::from_seconds(1)) + .unwrap(), + 1 + ); + + // t=7, cardinality=0. 5 slashed, 5 unbonded. + assert_eq!( + st.validator_cardinality(storage, Timestamp::from_seconds(7)) + .unwrap(), + 0 + ); + // t=8, cardinality=1. 5 bonded. + assert_eq!( + st.validator_cardinality(storage, Timestamp::from_seconds(8)) + .unwrap(), + 1 + ); +} + +/// @t=0, staked to two validators +/// unbonding_duration = 5 +/// +/// @t=1, unbond from validator 1 +/// @t=2, slash of all unbonding tokens for validator 1, cardinality reduced +/// @t=3, unbond from validator 2 +/// @t=4, t=2 slash registered +#[test] +fn test_unbonding_slash() { + let storage = &mut mock_dependencies().storage; + let st = StakeTracker::new("s", "v", "c"); + + let delegation = Uint128::new(10); + let unbonding_duration = 5; + + // @t=0, staked to two validators + st.on_delegate( + storage, + Timestamp::from_seconds(0), + "v1".to_string(), + delegation, + ) + .unwrap(); + st.on_delegate( + storage, + Timestamp::from_seconds(0), + "v2".to_string(), + delegation, + ) + .unwrap(); + + // @t=1, unbond from validator 1 + st.on_undelegate( + storage, + Timestamp::from_seconds(1), + "v1".to_string(), + delegation, + unbonding_duration, + ) + .unwrap(); + + // @t=3, unbond from validator 2 + st.on_undelegate( + storage, + Timestamp::from_seconds(3), + "v2".to_string(), + delegation, + unbonding_duration, + ) + .unwrap(); + + // check that values @t=2 are correct w/o slash registered. + let total = st + .total_staked(storage, Timestamp::from_seconds(2)) + .unwrap(); + let cardinality = st + .validator_cardinality(storage, Timestamp::from_seconds(2)) + .unwrap(); + let v1 = st + .validator_staked(storage, Timestamp::from_seconds(2), "v1".to_string()) + .unwrap(); + let v2 = st + .validator_staked(storage, Timestamp::from_seconds(2), "v2".to_string()) + .unwrap(); + + assert_eq!(total, delegation + delegation); + assert_eq!(cardinality, 2); + assert_eq!(v1, delegation); + assert_eq!(v2, delegation); + + // check that the cardinality reduces after v1's unbond @t=1. + let cardinality_after_v1_unbond = st + .validator_cardinality(storage, Timestamp::from_seconds(1 + unbonding_duration)) + .unwrap(); + let v1_after_unbond = st + .validator_staked(storage, Timestamp::from_seconds(6), "v1".to_string()) + .unwrap(); + assert_eq!(v1_after_unbond, Uint128::zero()); + assert_eq!(cardinality_after_v1_unbond, 1); + + // @t=2, slash of all unbonding tokens for validator 1 + // cardinality reduced to 1 at t=2. + st.on_unbonding_slash( + storage, + Timestamp::from_seconds(2), + "v1".to_string(), + delegation, + ) + .unwrap(); + + // check that cardinality, validator staked, and total staked now look as expected. + let cardinality = st + .validator_cardinality(storage, Timestamp::from_seconds(2)) + .unwrap(); + assert_eq!(cardinality, 1); + let v1 = st + .validator_staked(storage, Timestamp::from_seconds(2), "v1".to_string()) + .unwrap(); + assert_eq!(v1, Uint128::zero()); + + // post-slash value remains zero. + let v1 = st + .validator_staked(storage, Timestamp::from_seconds(8), "v1".to_string()) + .unwrap(); + assert_eq!(v1, Uint128::zero()); + + // @t=6, two more seconds of unbonding left for v2. + let v2 = st + .validator_staked(storage, Timestamp::from_seconds(6), "v2".to_string()) + .unwrap(); + assert_eq!(v2, delegation); + let cardinality = st + .validator_cardinality(storage, Timestamp::from_seconds(6)) + .unwrap(); + assert_eq!(cardinality, 1); + + // @t=8 all unbonding has completed. + let v2 = st + .validator_staked(storage, Timestamp::from_seconds(8), "v2".to_string()) + .unwrap(); + assert_eq!(v2, Uint128::zero()); + let v1 = st + .validator_staked(storage, Timestamp::from_seconds(8), "v1".to_string()) + .unwrap(); + assert_eq!(v1, Uint128::zero()); + let cardinality = st + .validator_cardinality(storage, Timestamp::from_seconds(8)) + .unwrap(); + assert_eq!(cardinality, 0); +} + +/// Redelegating should cause cardinality changes if redelegation +/// removes all tokens from the source validator, or if it delegates +/// to a new validator. +#[test] +fn test_redelegation_changes_cardinality() { + let storage = &mut mock_dependencies().storage; + let st = StakeTracker::new("s", "v", "c"); + let t = Timestamp::default(); + let amount = Uint128::new(10); + + st.on_delegate(storage, t, "v1".to_string(), amount + amount) + .unwrap(); + let c = st.validator_cardinality(storage, t).unwrap(); + assert_eq!(c, 1); + + st.on_redelegate(storage, t, "v1".to_string(), "v2".to_string(), amount) + .unwrap(); + let c = st.validator_cardinality(storage, t).unwrap(); + assert_eq!(c, 2); + + st.on_redelegate(storage, t, "v1".to_string(), "v2".to_string(), amount) + .unwrap(); + let c = st.validator_cardinality(storage, t).unwrap(); + assert_eq!(c, 1); +} + +#[test] +fn test_queries() { + let storage = &mut mock_dependencies().storage; + let st = StakeTracker::new("s", "v", "c"); + st.on_delegate( + storage, + Timestamp::from_seconds(10), + "v1".to_string(), + Uint128::new(42), + ) + .unwrap(); + + let cardinality: Uint128 = from_binary( + &st.query( + storage, + StakeTrackerQuery::Cardinality { + t: Timestamp::from_seconds(11), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(cardinality, Uint128::one()); + + let total_staked: Uint128 = from_binary( + &st.query( + storage, + StakeTrackerQuery::TotalStaked { + t: Timestamp::from_seconds(10), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(total_staked, Uint128::new(42)); + + let val_staked: Uint128 = from_binary( + &st.query( + storage, + StakeTrackerQuery::ValidatorStaked { + t: Timestamp::from_seconds(10), + validator: "v1".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(val_staked, Uint128::new(42)); + + let val_staked_before_staking: Uint128 = from_binary( + &st.query( + storage, + StakeTrackerQuery::ValidatorStaked { + t: Timestamp::from_seconds(9), + validator: "v1".to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(val_staked_before_staking, Uint128::new(0)); +} diff --git a/packages/cw-wormhole/Cargo.toml b/packages/cw-wormhole/Cargo.toml new file mode 100644 index 000000000..65650462f --- /dev/null +++ b/packages/cw-wormhole/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cw-wormhole" +authors = ["ekez "] +description = "A CosmWasm map that allows incrementing and decrementing values from the past." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +serde = { workspace = true } diff --git a/packages/cw-wormhole/README.md b/packages/cw-wormhole/README.md new file mode 100644 index 000000000..3551809f9 --- /dev/null +++ b/packages/cw-wormhole/README.md @@ -0,0 +1,62 @@ +# 🌀⏱️ CW Wormhole ⏱️🌀 + +A CosmWasm KV store that allows setting values from the past. For +example: + +```rust +use cosmwasm_std::{testing::mock_dependencies, Uint128, Addr}; +use cw_wormhole::Wormhole; +let storage = &mut mock_dependencies().storage; +let w: Wormhole = Wormhole::new("ns"); +let key = Addr::unchecked("violet"); + +// increment the value by one at time 10. +w.increment(storage, key.clone(), 10, Uint128::new(1)) + .unwrap(); + +// increment the value by two at time 9. +w.increment(storage, key.clone(), 9, Uint128::new(2)) + .unwrap(); + +// the value at time 10 is now three. +assert_eq!( + w.load(storage, key, 10).unwrap(), + Some(Uint128::new(3)) +); +``` + +Loading a value from the map is always constant time. Updating values +in the map is O(# future values). This has the effect of moving the +complexity of incrementing a future value into the present. + +For a more in-depth analysis of the runtime of this data structure, +please see [this +essay](https://gist.github.com/0xekez/15fab6436ed593cbd59f0bdf7ecf1f61). + +## Limitations + +Reference types may not be used as keys. + +Consider the trait bound: + +```text + for<'a> &'a (K, u64): PrimaryKey<'a> +``` + +This bound says, for any lifetime `'a` a reference to the tuple `(K, +u64)` will be a valid `PrimaryKey` with lifetime `'a`, thus we can +store tuples of this type in the map. + +In order to allow K to have a lifetime (call it `'k`), we'd need to +write: + +```text + for<'a where 'a: 'k> &'a (K, u64): PrimaryKey<'a> +``` + +As the lifetime of the primary key is `'a + 'k` (the minimum of the +key's lifetime and the tuple's lifetime). + +Unfourtunately, Rust does not support this. There is an RFC to +implement it +[here](https://github.com/tema3210/rfcs/blob/extended_hrtbs/text/3621-extended_hrtb.md). diff --git a/packages/cw-wormhole/src/lib.rs b/packages/cw-wormhole/src/lib.rs new file mode 100644 index 000000000..71a0faa5c --- /dev/null +++ b/packages/cw-wormhole/src/lib.rs @@ -0,0 +1,226 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +use std::{ + marker::PhantomData, + ops::{Add, Sub}, +}; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use cosmwasm_std::{Order, StdResult, Storage}; +use cw_storage_plus::{Bound, KeyDeserialize, Map, PrimaryKey}; + +/// A map that ensures that the gas cost of updating a value is higher +/// than the cost of loading a value and allows updating values in the +/// future. The cost of loading a value from this map is O(1) in gas. +/// +/// This map has a special high-performance case if it is being used +/// to track unbonding tokens. In that case, the runtime to update a +/// key is O(# times unbonding duration has changed). For a proof of +/// this, and further runtime analysis see [this +/// essay](https://gist.github.com/0xekez/15fab6436ed593cbd59f0bdf7ecf1f61). +/// +/// # Example +/// +/// ``` +/// # use cosmwasm_std::{testing::mock_dependencies, Uint128}; +/// # use cw_wormhole::Wormhole; +/// let storage = &mut mock_dependencies().storage; +/// let fm: Wormhole = Wormhole::new("ns"); +/// +/// fm.increment(storage, "fm".to_string(), 10, Uint128::new(1)) +/// .unwrap(); +/// fm.increment(storage, "fm".to_string(), 9, Uint128::new(2)) +/// .unwrap(); +/// +/// // no value exists at time=8 +/// assert_eq!(fm.load(storage, "fm".to_string(), 8).unwrap(), None); +/// // value was incremented by 2 at time=9 +/// assert_eq!( +/// fm.load(storage, "fm".to_string(), 9).unwrap(), +/// Some(Uint128::new(2)) +/// ); +/// // value was incremented by 1 at time=10 making final value 3 +/// assert_eq!( +/// fm.load(storage, "fm".to_string(), 10).unwrap(), +/// Some(Uint128::new(3)) +/// ); +/// ``` +pub struct Wormhole<'n, K, V> { + namespace: &'n str, + k: PhantomData, + v: PhantomData, +} + +impl<'n, K, V> Wormhole<'n, K, V> { + /// Creates a new map using the provided namespace. + /// + /// The namespace identifies the prefix in the SDK's prefix + /// store that values and keys will be stored under. + /// + /// # Example + /// + /// ``` + /// # use cw_wormhole::Wormhole; + /// # use cosmwasm_std::{Addr, Uint128}; + /// + /// pub const MAP: Wormhole<&Addr, Uint128> = Wormhole::new("unbonded_balances"); + /// ``` + pub const fn new(namespace: &'n str) -> Self { + Self { + namespace, + k: PhantomData, + v: PhantomData, + } + } +} + +impl<'n, K, V> Wormhole<'n, K, V> +where + // 1. values in the map can be serialized and deserialized + V: Serialize + DeserializeOwned + Default + Clone, + // 1.1. keys in the map can be cloned + K: Clone, + // 2. &(key, time) is a value key in a map + for<'a> &'a (K, u64): PrimaryKey<'a>, + // 3. the suffix of (2) is a valid key and constructable from a + // time (u64) + for<'a> <&'a (K, u64) as PrimaryKey<'a>>::Suffix: PrimaryKey<'a> + From, + // 4. K can be converted into the prefix of (2) + for<'a> K: Into<<&'a (K, u64) as PrimaryKey<'a>>::Prefix>, + // 5. when deserializing a key the result has a static lifetime + // and can be converted into a key. required by the `range` + // call in the `load` method + for<'a> <<&'a (K, u64) as PrimaryKey<'a>>::Suffix as KeyDeserialize>::Output: + 'static + Into + Copy, +{ + /// Loads the value at a key at the specified time. If the key has + /// no value at that time, returns `None`. Returns `Some(value)` + /// otherwise. + pub fn load(&self, storage: &dyn Storage, k: K, t: u64) -> StdResult> { + let now = Bound::inclusive(t); + Ok(self + .snapshots() + .prefix(k.into()) + .range(storage, None, Some(now), Order::Descending) + .next() + .transpose()? + .map(|(_k, v)| v)) + } + + /// Increments the value of key `k` at time `t` by amount `i`. + pub fn increment(&self, storage: &mut dyn Storage, k: K, t: u64, i: V) -> StdResult + where + V: Add, + { + self.update(storage, k, t, &mut |v, _| v + i.clone()) + } + + /// Decrements the value of key `k` at time `t` by amount `i`. + pub fn decrement(&self, storage: &mut dyn Storage, k: K, t: u64, i: V) -> StdResult + where + V: Sub, + { + self.update(storage, k, t, &mut |v, _| v - i.clone()) + } + + /// Gets the snapshot map with a namespace with a lifetime equal + /// to the lifetime of `&'a self`. + const fn snapshots<'a>(&self) -> Map<'n, &'a (K, u64), V> { + Map::new(self.namespace) + } + + /// Updates `k` at time `t`. To do so, update is called on the + /// current value of `k` (or Default::default() if there is no + /// current value), and then all future (t' > t) values of `k`. + /// + /// For example, to perform a increment operation, the `update` + /// function used is `|v| v + amount`. + /// + /// The new value at `t` is returned. + pub fn update( + &self, + storage: &mut dyn Storage, + k: K, + t: u64, + update: &mut dyn FnMut(V, u64) -> V, + ) -> StdResult { + // Update the value at t. + let prev = self.load(storage, k.clone(), t)?.unwrap_or_default(); + let updated = update(prev, t); + self.snapshots().save(storage, &(k.clone(), t), &updated)?; + + // Update all values where t' > t. + for (t, v) in self + .snapshots() + .prefix(k.clone().into()) + .range(storage, Some(Bound::exclusive(t)), None, Order::Ascending) + .collect::>>()? + .into_iter() + { + self.snapshots() + .save(storage, &(k.clone(), t.into()), &update(v, t.into()))?; + } + Ok(updated) + } + + /// Updates a single key `k` at time `t` without performing an + /// update on values of `(k, t')` where `t' > t`. + /// + /// This is safe to use if updating a the key at the specified + /// time is not expected to impact values of the key \forall t' > + /// t. If you want to update a key and also update future values + /// of that key, (which is likely what you normally want) use the + /// `update` method. + /// + /// ```text + /// Unbonding Slash (Tokens / Time) + /// 30 +------------------------------------------------------------------+ + /// | + + + + | + /// | w/o slash +.....+ | + /// 25 |-+ w/ slash =======-| + /// | | + /// | | + /// | | + /// 20 |===========================............+.............+ +-| + /// | = : | + /// | = : | + /// 15 |-+ ============================ +-| + /// | = | + /// | = | + /// | = | + /// 10 |-+ =============| + /// | | + /// | + + + + | + /// 5 +------------------------------------------------------------------+ + /// 0 1 2 3 4 5 + /// ^ ^ ^ + /// | | | + /// Unbonding Start Slash Unbonded + /// + /// Time -> + /// ``` + /// + /// For example, consider the above graph showing bonded + + /// unbonded tokens over time with a slash ocuring at `t=2`. In + /// this case, the slash does not impact the value at `t=4` (when + /// unbonding completes), but it does change intermediate values, + /// so it is safe to use `dangerously_update` to register the + /// slash at t=2. + pub fn dangerously_update( + &self, + storage: &mut dyn Storage, + k: K, + t: u64, + update: &mut dyn FnMut(V, u64) -> V, + ) -> StdResult { + let prev = self.load(storage, k.clone(), t)?.unwrap_or_default(); + let updated = update(prev, t); + self.snapshots().save(storage, &(k, t), &updated)?; + Ok(updated) + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/cw-wormhole/src/tests.rs b/packages/cw-wormhole/src/tests.rs new file mode 100644 index 000000000..ae5e50df9 --- /dev/null +++ b/packages/cw-wormhole/src/tests.rs @@ -0,0 +1,111 @@ +use cosmwasm_std::{testing::mock_dependencies, Uint128}; + +use crate::Wormhole; + +#[test] +fn test_increment() { + let storage = &mut mock_dependencies().storage; + let w: Wormhole = Wormhole::new("ns"); + + w.increment(storage, "ekez".to_string(), 10, Uint128::new(1)) + .unwrap(); + // incrementing 9 shoud cause the value at 10 to become 3 + w.increment(storage, "ekez".to_string(), 9, Uint128::new(2)) + .unwrap(); + + assert_eq!(w.load(storage, "ekez".to_string(), 8).unwrap(), None); + assert_eq!( + w.load(storage, "ekez".to_string(), 9).unwrap(), + Some(Uint128::new(2)) + ); + assert_eq!( + w.load(storage, "ekez".to_string(), 10).unwrap(), + Some(Uint128::new(3)) + ); +} + +#[test] +fn test_decrement() { + let storage = &mut mock_dependencies().storage; + let w: Wormhole = Wormhole::new("ns"); + + w.increment(storage, 1, 11, 4).unwrap(); + w.increment(storage, 1, 10, 10).unwrap(); + + w.decrement(storage, 1, 9, 4).unwrap(); + + assert_eq!(w.load(storage, 1, 8).unwrap(), None); + assert_eq!(w.load(storage, 1, 9).unwrap(), Some(-4)); + assert_eq!(w.load(storage, 1, 10).unwrap(), Some(6)); + assert_eq!(w.load(storage, 1, 11).unwrap(), Some(10)); +} + +#[test] +fn test_load_matches_returned() { + let storage = &mut mock_dependencies().storage; + let w: Wormhole<(), u32> = Wormhole::new("ns"); + + let v = w.increment(storage, (), 10, 10).unwrap(); + assert_eq!(v, w.load(storage, (), 10).unwrap().unwrap()); + + let v = w.decrement(storage, (), 11, 1).unwrap(); + assert_eq!(v, w.load(storage, (), 11).unwrap().unwrap()); + assert_eq!(v, 9); +} + +/// Calls to update should visit values in ascending order in terms of +/// time. +#[test] +fn test_update_visits_in_ascending_order() { + let storage = &mut mock_dependencies().storage; + let w: Wormhole<(), u32> = Wormhole::new("ns"); + + w.increment(storage, (), 10, 10).unwrap(); + w.decrement(storage, (), 11, 1).unwrap(); + + let mut seen = vec![]; + w.update(storage, (), 8, &mut |v, t| { + seen.push((t, v)); + v + }) + .unwrap(); + + assert_eq!(seen, vec![(8, 0), (10, 10), (11, 9)]) +} + +/// Construct's the graph shown in the `dangerously_update` docstring +/// and verifies that the method behaves as expected. +#[test] +fn test_dangerous_update() { + let storage = &mut mock_dependencies().storage; + let w: Wormhole<(), u32> = Wormhole::new("ns"); + + // (0) -> 20 + // (4) -> 10 + w.increment(storage, (), 0, 20).unwrap(); + w.decrement(storage, (), 4, 10).unwrap(); + + // (3) -> 20 + let v = w.load(storage, (), 3).unwrap().unwrap(); + assert_eq!(v, 20); + + // (2) -> 15 + let also_v = w + .dangerously_update(storage, (), 2, &mut |v, _| v - 5) + .unwrap(); + + // (2) -> 15 + let v = w.load(storage, (), 2).unwrap().unwrap(); + assert_eq!(v, 15); + // check that returned value is same as loaded one. + assert_eq!(also_v, 15); + + // (3) -> 15 + let v = w.load(storage, (), 3).unwrap().unwrap(); + assert_eq!(v, 15); + + // (4) -> 10, as dangerously_update should not change already set + // values. + let v = w.load(storage, (), 4).unwrap().unwrap(); + assert_eq!(v, 10); +} diff --git a/packages/cw721-controllers/Cargo.toml b/packages/cw721-controllers/Cargo.toml new file mode 100644 index 000000000..b497f17c0 --- /dev/null +++ b/packages/cw721-controllers/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cw721-controllers" +authors = ["CypherApe cypherape@protonmail.com"] +description = "A package for managing cw721 claims." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-utils = { workspace = true } +cw-storage-plus = { workspace = true } +thiserror = { workspace = true } diff --git a/packages/cw721-controllers/README.md b/packages/cw721-controllers/README.md new file mode 100644 index 000000000..455c591f7 --- /dev/null +++ b/packages/cw721-controllers/README.md @@ -0,0 +1,6 @@ +# CW721 Controllers: Common cw721 controllers for many contracts + +This is an implementation of cw-plus' +[cw-controllers](https://github.com/CosmWasm/cw-plus/tree/72afcde846b907fac5c0394ce86ed5a59ce47524/packages/controllers) +package for CW721 NFTs. It manages claims for our [cw721 staking +contract](../../contracts/voting/dao-voting-cw721-staked). diff --git a/packages/cw721-controllers/src/lib.rs b/packages/cw721-controllers/src/lib.rs new file mode 100644 index 000000000..d4f58091a --- /dev/null +++ b/packages/cw721-controllers/src/lib.rs @@ -0,0 +1,5 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +mod nft_claim; + +pub use nft_claim::{NftClaim, NftClaims, NftClaimsResponse}; diff --git a/packages/cw721-controllers/src/nft_claim.rs b/packages/cw721-controllers/src/nft_claim.rs new file mode 100644 index 000000000..4b8702f9a --- /dev/null +++ b/packages/cw721-controllers/src/nft_claim.rs @@ -0,0 +1,405 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, BlockInfo, CustomQuery, Deps, StdResult, Storage}; +use cw_storage_plus::Map; +use cw_utils::Expiration; + +#[cw_serde] +pub struct NftClaimsResponse { + pub nft_claims: Vec, +} + +#[cw_serde] +pub struct NftClaim { + pub token_id: String, + pub release_at: Expiration, +} + +impl NftClaim { + pub fn new(token_id: String, released: Expiration) -> Self { + NftClaim { + token_id, + release_at: released, + } + } +} + +pub struct NftClaims<'a>(Map<'a, &'a Addr, Vec>); + +impl<'a> NftClaims<'a> { + pub const fn new(storage_key: &'a str) -> Self { + NftClaims(Map::new(storage_key)) + } + + /// Creates a number of NFT claims simeltaniously for a given + /// address. + /// + /// # Invariants + /// + /// - token_ids must be deduplicated + /// - token_ids must not contain any IDs which are currently in + /// the claims queue for ADDR. This can be ensured by requiring + /// that claims are completed before the tokens may be restaked. + pub fn create_nft_claims( + &self, + storage: &mut dyn Storage, + addr: &Addr, + token_ids: Vec, + release_at: Expiration, + ) -> StdResult<()> { + self.0.update(storage, addr, |old| -> StdResult<_> { + Ok(old + .unwrap_or_default() + .into_iter() + .chain(token_ids.into_iter().map(|token_id| NftClaim { + token_id, + release_at, + })) + .collect::>()) + })?; + Ok(()) + } + + /// This iterates over all mature claims for the address, and removes them, up to an optional cap. + /// it removes the finished claims and returns the total amount of tokens to be released. + pub fn claim_nfts( + &self, + storage: &mut dyn Storage, + addr: &Addr, + block: &BlockInfo, + ) -> StdResult> { + let mut to_send = vec![]; + self.0.update(storage, addr, |nft_claims| -> StdResult<_> { + let (_send, waiting): (Vec<_>, _) = + nft_claims.unwrap_or_default().into_iter().partition(|c| { + // if mature and we can pay fully, then include in _send + if c.release_at.is_expired(block) { + to_send.push(c.token_id.clone()); + true + } else { + // not to send, leave in waiting and save again + false + } + }); + Ok(waiting) + })?; + Ok(to_send) + } + + pub fn query_claims( + &self, + deps: Deps, + address: &Addr, + ) -> StdResult { + let nft_claims = self.0.may_load(deps.storage, address)?.unwrap_or_default(); + Ok(NftClaimsResponse { nft_claims }) + } +} + +#[cfg(test)] +mod test { + use cosmwasm_std::{ + testing::{mock_dependencies, mock_env}, + Order, + }; + + use super::*; + const TEST_BAYC_TOKEN_ID: &str = "BAYC"; + const TEST_CRYPTO_PUNKS_TOKEN_ID: &str = "CRYPTOPUNKS"; + const TEST_EXPIRATION: Expiration = Expiration::AtHeight(10); + + #[test] + fn can_create_claim() { + let claim = NftClaim::new(TEST_BAYC_TOKEN_ID.to_string(), TEST_EXPIRATION); + assert_eq!(claim.token_id, TEST_BAYC_TOKEN_ID.to_string()); + assert_eq!(claim.release_at, TEST_EXPIRATION); + } + + #[test] + fn can_create_claims() { + let deps = mock_dependencies(); + let claims = NftClaims::new("claims"); + // Assert that claims creates a map and there are no keys in the map. + assert_eq!( + claims + .0 + .range_raw(&deps.storage, None, None, Order::Ascending) + .collect::>>() + .unwrap() + .len(), + 0 + ); + } + + #[test] + fn check_create_claim_updates_map() { + let mut deps = mock_dependencies(); + let claims = NftClaims::new("claims"); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_BAYC_TOKEN_ID.into()], + TEST_EXPIRATION, + ) + .unwrap(); + + // Assert that claims creates a map and there is one claim for the address. + let saved_claims = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr")) + .unwrap(); + assert_eq!(saved_claims.len(), 1); + assert_eq!(saved_claims[0].token_id, TEST_BAYC_TOKEN_ID.to_string()); + assert_eq!(saved_claims[0].release_at, TEST_EXPIRATION); + + // Adding another claim to same address, make sure that both claims are saved. + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_CRYPTO_PUNKS_TOKEN_ID.into()], + TEST_EXPIRATION, + ) + .unwrap(); + + // Assert that both claims exist for the address. + let saved_claims = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr")) + .unwrap(); + assert_eq!(saved_claims.len(), 2); + assert_eq!(saved_claims[0].token_id, TEST_BAYC_TOKEN_ID.to_string()); + assert_eq!(saved_claims[0].release_at, TEST_EXPIRATION); + assert_eq!( + saved_claims[1].token_id, + TEST_CRYPTO_PUNKS_TOKEN_ID.to_string() + ); + assert_eq!(saved_claims[1].release_at, TEST_EXPIRATION); + + // Adding another claim to different address, make sure that other address only has one claim. + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr2"), + vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()], + TEST_EXPIRATION, + ) + .unwrap(); + + // Assert that both claims exist for the address. + let saved_claims = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr")) + .unwrap(); + + let saved_claims_addr2 = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr2")) + .unwrap(); + assert_eq!(saved_claims.len(), 2); + assert_eq!(saved_claims_addr2.len(), 1); + } + + #[test] + fn test_claim_tokens_with_no_claims() { + let mut deps = mock_dependencies(); + let claims = NftClaims::new("claims"); + + let nfts = claims + .claim_nfts( + deps.as_mut().storage, + &Addr::unchecked("addr"), + &mock_env().block, + ) + .unwrap(); + let saved_claims = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr")) + .unwrap(); + + assert_eq!(nfts.len(), 0); + assert_eq!(saved_claims.len(), 0); + } + + #[test] + fn test_claim_tokens_with_no_released_claims() { + let mut deps = mock_dependencies(); + let claims = NftClaims::new("claims"); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()], + Expiration::AtHeight(10), + ) + .unwrap(); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_BAYC_TOKEN_ID.to_string()], + Expiration::AtHeight(100), + ) + .unwrap(); + + let mut env = mock_env(); + env.block.height = 0; + // the address has two claims however they are both not expired + let nfts = claims + .claim_nfts(deps.as_mut().storage, &Addr::unchecked("addr"), &env.block) + .unwrap(); + + let saved_claims = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr")) + .unwrap(); + + assert_eq!(nfts.len(), 0); + assert_eq!(saved_claims.len(), 2); + assert_eq!( + saved_claims[0].token_id, + TEST_CRYPTO_PUNKS_TOKEN_ID.to_string() + ); + assert_eq!(saved_claims[0].release_at, Expiration::AtHeight(10)); + assert_eq!(saved_claims[1].token_id, TEST_BAYC_TOKEN_ID.to_string()); + assert_eq!(saved_claims[1].release_at, Expiration::AtHeight(100)); + } + + #[test] + fn test_claim_tokens_with_one_released_claim() { + let mut deps = mock_dependencies(); + let claims = NftClaims::new("claims"); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_BAYC_TOKEN_ID.to_string()], + Expiration::AtHeight(10), + ) + .unwrap(); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()], + Expiration::AtHeight(100), + ) + .unwrap(); + + let mut env = mock_env(); + env.block.height = 20; + // the address has two claims and the first one can be released + let nfts = claims + .claim_nfts(deps.as_mut().storage, &Addr::unchecked("addr"), &env.block) + .unwrap(); + + let saved_claims = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr")) + .unwrap(); + + assert_eq!(nfts.len(), 1); + assert_eq!(nfts[0], TEST_BAYC_TOKEN_ID.to_string()); + assert_eq!(saved_claims.len(), 1); + assert_eq!( + saved_claims[0].token_id, + TEST_CRYPTO_PUNKS_TOKEN_ID.to_string() + ); + assert_eq!(saved_claims[0].release_at, Expiration::AtHeight(100)); + } + + #[test] + fn test_claim_tokens_with_all_released_claims() { + let mut deps = mock_dependencies(); + let claims = NftClaims::new("claims"); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_BAYC_TOKEN_ID.to_string()], + Expiration::AtHeight(10), + ) + .unwrap(); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()], + Expiration::AtHeight(100), + ) + .unwrap(); + + let mut env = mock_env(); + env.block.height = 1000; + // the address has two claims and both can be released + let nfts = claims + .claim_nfts(deps.as_mut().storage, &Addr::unchecked("addr"), &env.block) + .unwrap(); + + let saved_claims = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr")) + .unwrap(); + + assert_eq!( + nfts, + vec![ + TEST_BAYC_TOKEN_ID.to_string(), + TEST_CRYPTO_PUNKS_TOKEN_ID.to_string() + ] + ); + assert_eq!(saved_claims.len(), 0); + } + + #[test] + fn test_query_claims_returns_correct_claims() { + let mut deps = mock_dependencies(); + let claims = NftClaims::new("claims"); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()], + Expiration::AtHeight(10), + ) + .unwrap(); + + let queried_claims = claims + .query_claims(deps.as_ref(), &Addr::unchecked("addr")) + .unwrap(); + let saved_claims = claims + .0 + .load(deps.as_mut().storage, &Addr::unchecked("addr")) + .unwrap(); + assert_eq!(queried_claims.nft_claims, saved_claims); + } + + #[test] + fn test_query_claims_returns_empty_for_non_existent_user() { + let mut deps = mock_dependencies(); + let claims = NftClaims::new("claims"); + + claims + .create_nft_claims( + deps.as_mut().storage, + &Addr::unchecked("addr"), + vec![TEST_CRYPTO_PUNKS_TOKEN_ID.to_string()], + Expiration::AtHeight(10), + ) + .unwrap(); + + let queried_claims = claims + .query_claims(deps.as_ref(), &Addr::unchecked("addr2")) + .unwrap(); + + assert_eq!(queried_claims.nft_claims.len(), 0); + } +} diff --git a/packages/dao-cw721-extensions/Cargo.toml b/packages/dao-cw721-extensions/Cargo.toml new file mode 100644 index 000000000..609e89d0d --- /dev/null +++ b/packages/dao-cw721-extensions/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dao-cw721-extensions" +authors = ["Jake Hartnell"] +description = "A package for DAO cw721 extensions." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-controllers = { workspace = true } +cw4 = { workspace = true } diff --git a/packages/dao-cw721-extensions/README.md b/packages/dao-cw721-extensions/README.md new file mode 100644 index 000000000..4fae07714 --- /dev/null +++ b/packages/dao-cw721-extensions/README.md @@ -0,0 +1,3 @@ +# DAO CW721 Extensions: extensions for DAO related NFT contracts + +This implements extensions for cw721 NFT contracts integrating with DAO DAO (for example `cw721-roles`). diff --git a/packages/dao-cw721-extensions/src/lib.rs b/packages/dao-cw721-extensions/src/lib.rs new file mode 100644 index 000000000..22bacf03c --- /dev/null +++ b/packages/dao-cw721-extensions/src/lib.rs @@ -0,0 +1,3 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod roles; diff --git a/packages/dao-cw721-extensions/src/roles.rs b/packages/dao-cw721-extensions/src/roles.rs new file mode 100644 index 000000000..0f2c9166a --- /dev/null +++ b/packages/dao-cw721-extensions/src/roles.rs @@ -0,0 +1,56 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::CustomMsg; + +#[cw_serde] +pub struct MetadataExt { + /// Optional on-chain role for this member, can be used by other contracts to enforce permissions + pub role: Option, + /// The voting weight of this role + pub weight: u64, +} + +#[cw_serde] +pub enum ExecuteExt { + /// Add a new hook to be informed of all membership changes. + /// Must be called by Admin + AddHook { addr: String }, + /// Remove a hook. Must be called by Admin + RemoveHook { addr: String }, + /// Update the token_uri for a particular NFT. Must be called by minter / admin + UpdateTokenUri { + token_id: String, + token_uri: Option, + }, + /// Updates the voting weight of a token. Must be called by minter / admin + UpdateTokenWeight { token_id: String, weight: u64 }, + /// Udates the role of a token. Must be called by minter / admin + UpdateTokenRole { + token_id: String, + role: Option, + }, +} +impl CustomMsg for ExecuteExt {} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryExt { + /// Total weight at a given height + #[returns(cw4::TotalWeightResponse)] + TotalWeight { at_height: Option }, + /// Returns a list of Members + #[returns(cw4::MemberListResponse)] + ListMembers { + start_after: Option, + limit: Option, + }, + /// Returns the weight of a certain member + #[returns(cw4::MemberResponse)] + Member { + addr: String, + at_height: Option, + }, + /// Shows all registered hooks. + #[returns(cw_controllers::HooksResponse)] + Hooks {}, +} +impl CustomMsg for QueryExt {} diff --git a/packages/dao-dao-macros/.cargo/config b/packages/dao-dao-macros/.cargo/config new file mode 100644 index 000000000..e44e70f31 --- /dev/null +++ b/packages/dao-dao-macros/.cargo/config @@ -0,0 +1,2 @@ +[alias] +schema = "run --example schema" diff --git a/packages/dao-dao-macros/Cargo.toml b/packages/dao-dao-macros/Cargo.toml new file mode 100644 index 000000000..0d9a0f52b --- /dev/null +++ b/packages/dao-dao-macros/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "dao-dao-macros" +authors = ["ekez ekez@withoutdoing.com"] +description = "A package macros for deriving DAO module interfaces." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +proc-macro = true + +[dependencies] +cosmwasm-schema = { workspace = true } +syn = { workspace = true } +quote = { workspace = true } +proc-macro2 = { workspace = true } + +[dev-dependencies] +dao-interface = { workspace = true } +cw-hooks = { workspace = true } +dao-voting = { workspace = true } +cosmwasm-std = { workspace = true } diff --git a/packages/dao-dao-macros/README.md b/packages/dao-dao-macros/README.md new file mode 100644 index 000000000..404f8acda --- /dev/null +++ b/packages/dao-dao-macros/README.md @@ -0,0 +1,18 @@ +# CosmWasm DAO Macros + +This package provides a collection of macros that may be used to +derive DAO module interfaces on message enums. For example, to derive +the voting module interface on an enum: + +```rust +use cosmwasm_schema::{cw_serde, QueryResponses}; +use dao_dao_macros::{token_query, voting_module_query}; +use dao_interface::voting::TotalPowerAtHeightResponse; +use dao_interface::voting::VotingPowerAtHeightResponse; + +#[token_query] +#[voting_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum Query {} +``` diff --git a/packages/dao-dao-macros/src/lib.rs b/packages/dao-dao-macros/src/lib.rs new file mode 100644 index 000000000..4c11c8b71 --- /dev/null +++ b/packages/dao-dao-macros/src/lib.rs @@ -0,0 +1,399 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +use proc_macro::TokenStream; + +use quote::quote; +use syn::{parse_macro_input, AttributeArgs, DataEnum, DeriveInput, Path}; + +// Merges the variants of two enums. +fn merge_variants(metadata: TokenStream, left: TokenStream, right: TokenStream) -> TokenStream { + use syn::Data::Enum; + + let args = parse_macro_input!(metadata as AttributeArgs); + if let Some(first_arg) = args.first() { + return syn::Error::new_spanned(first_arg, "macro takes no arguments") + .to_compile_error() + .into(); + } + + let mut left: DeriveInput = parse_macro_input!(left); + let right: DeriveInput = parse_macro_input!(right); + + if let ( + Enum(DataEnum { variants, .. }), + Enum(DataEnum { + variants: to_add, .. + }), + ) = (&mut left.data, right.data) + { + variants.extend(to_add.into_iter()); + + quote! { #left }.into() + } else { + syn::Error::new(left.ident.span(), "variants may only be added for enums") + .to_compile_error() + .into() + } +} + +/// Gets the dao_interface path for something exported by +/// dao_interface. If we are currently compiling the dao-interface +/// crate, `crate::{internal}` is returned. If we are not, +/// `::dao_interface::{internal}` is returned. +/// +/// The this is needed is that dao_interface both defines types used +/// in the interfaces here, and uses the macros exported here. At some +/// point we'll be in a compilation context where we're inside of a +/// crate as the macro is being expanded, and we need to use types +/// local to the crate. +fn dao_interface_path(inside: &str) -> Path { + let pkg = std::env::var("CARGO_PKG_NAME").unwrap(); + let base = if pkg == "dao-interface" { + "crate" + } else { + "::dao_interface" + }; + let path = format!("{base}::{inside}"); + let path: Path = syn::parse_str(&path).unwrap(); + path +} + +/// Adds the necessary fields to an enum such that the enum implements the +/// interface needed to be a voting module. +/// +/// For example: +/// +/// ``` +/// use dao_dao_macros::voting_module_query; +/// use cosmwasm_schema::{cw_serde, QueryResponses}; +/// use dao_interface::voting::TotalPowerAtHeightResponse; +/// use dao_interface::voting::VotingPowerAtHeightResponse; +/// +/// #[voting_module_query] +/// #[cw_serde] +/// #[derive(QueryResponses)] +/// enum QueryMsg {} +/// +/// ``` +/// Will transform the enum to: +/// +/// ``` +/// +/// enum QueryMsg { +/// VotingPowerAtHeight { +/// address: String, +/// height: Option +/// }, +/// TotalPowerAtHeight { +/// height: Option +/// }, +/// Dao {}, +/// Info {}, +/// } +/// ``` +/// +/// Note that other derive macro invocations must occur after this +/// procedural macro as they may depend on the new fields. For +/// example, the following will fail becase the `Clone` derivation +/// occurs before the addition of the field. +/// +/// ```compile_fail +/// use dao_dao_macros::voting_module_query; +/// use cosmwasm_schema::{cw_serde, QueryResponses}; +/// use dao_interface::voting::TotalPowerAtHeightResponse; +/// use dao_interface::voting::VotingPowerAtHeightResponse; +/// +/// #[derive(Clone)] +/// #[voting_module_query] +/// #[allow(dead_code)] +/// #[cw_serde] +/// #[derive(QueryResponses)] +/// enum Test { +/// #[returns(String)] +/// Foo, +/// #[returns(String)] +/// Bar(u64), +/// #[returns(String)] +/// Baz { foo: u64 }, +/// } +/// ``` +#[proc_macro_attribute] +pub fn voting_module_query(metadata: TokenStream, input: TokenStream) -> TokenStream { + let i = dao_interface_path("voting::InfoResponse"); + let vp = dao_interface_path("voting::VotingPowerAtHeightResponse"); + let tp = dao_interface_path("voting::TotalPowerAtHeightResponse"); + + merge_variants( + metadata, + input, + quote! { + enum Right { + /// Returns the voting power for an address at a given height. + #[returns(#vp)] + VotingPowerAtHeight { + address: ::std::string::String, + height: ::std::option::Option<::std::primitive::u64> + }, + /// Returns the total voting power at a given block heigh. + #[returns(#tp)] + TotalPowerAtHeight { + height: ::std::option::Option<::std::primitive::u64> + }, + /// Returns the address of the DAO this module belongs to. + #[returns(cosmwasm_std::Addr)] + Dao {}, + /// Returns contract version info. + #[returns(#i)] + Info {} + } + } + .into(), + ) +} + +/// Adds the necessary fields to an enum such that it implements the +/// interface needed to be a voting module with a token. +/// +/// For example: +/// +/// ``` +/// use dao_dao_macros::token_query; +/// use cosmwasm_schema::{cw_serde, QueryResponses}; +/// use cosmwasm_std::Addr; +/// +/// #[token_query] +/// #[cw_serde] +/// #[derive(QueryResponses)] +/// enum QueryMsg {} +/// ``` +/// +/// Will transform the enum to: +/// +/// ``` +/// enum QueryMsg { +/// TokenContract {}, +/// } +/// ``` +/// +/// Note that other derive macro invocations must occur after this +/// procedural macro as they may depend on the new fields. For +/// example, the following will fail becase the `Clone` derivation +/// occurs before the addition of the field. +/// +/// ```compile_fail +/// use dao_dao_macros::token_query; +/// use cosmwasm_schema::{cw_serde, QueryResponses}; +/// +/// #[derive(Clone)] +/// #[token_query] +/// #[allow(dead_code)] +/// #[cw_serde] +/// #[derive(QueryResponses)] +/// enum Test { +/// Foo, +/// Bar(u64), +/// Baz { foo: u64 }, +/// } +/// ``` +#[proc_macro_attribute] +pub fn token_query(metadata: TokenStream, input: TokenStream) -> TokenStream { + merge_variants( + metadata, + input, + quote! { + enum Right { + #[returns(::cosmwasm_std::Addr)] + TokenContract {} + } + } + .into(), + ) +} + +/// Adds the necessary fields to an enum such that it implements the +/// interface needed to be a voting module that has an +/// active check threshold. +/// +/// For example: +/// +/// ``` +/// use dao_dao_macros::active_query; +/// use cosmwasm_schema::{cw_serde, QueryResponses}; +/// +/// #[active_query] +/// #[cw_serde] +/// #[derive(QueryResponses)] +/// enum QueryMsg {} +/// ``` +/// +/// Will transform the enum to: +/// +/// ``` +/// enum QueryMsg { +/// IsActive {}, +/// } +/// ``` +/// +/// Note that other derive macro invocations must occur after this +/// procedural macro as they may depend on the new fields. For +/// example, the following will fail becase the `Clone` derivation +/// occurs before the addition of the field. +/// +/// ```compile_fail +/// use dao_dao_macros::active_query; +/// +/// #[derive(Clone)] +/// #[active_query] +/// #[allow(dead_code)] +/// enum Test { +/// Foo, +/// Bar(u64), +/// Baz { foo: u64 }, +/// } +/// ``` +#[proc_macro_attribute] +pub fn active_query(metadata: TokenStream, input: TokenStream) -> TokenStream { + merge_variants( + metadata, + input, + quote! { + enum Right { + #[returns(::std::primitive::bool)] + IsActive {} + } + } + .into(), + ) +} + +/// Adds the necessary fields to an enum such that it implements the +/// interface needed to be a proposal module. +/// +/// For example: +/// +/// ``` +/// use dao_dao_macros::proposal_module_query; +/// use cosmwasm_schema::{cw_serde, QueryResponses}; +/// use cosmwasm_std::Addr; +/// +/// #[proposal_module_query] +/// #[cw_serde] +/// #[derive(QueryResponses)] +/// enum QueryMsg {} +/// ``` +/// +/// Will transform the enum to: +/// +/// ``` +/// enum QueryMsg { +/// Dao {}, +/// Info {}, +/// ProposalCreationPolicy {}, +/// ProposalHooks {}, +/// } +/// ``` +/// +/// Note that other derive macro invocations must occur after this +/// procedural macro as they may depend on the new fields. For +/// example, the following will fail becase the `Clone` derivation +/// occurs before the addition of the field. +/// +/// ```compile_fail +/// use dao_dao_macros::proposal_module_query; +/// use cosmwasm_schema::{cw_serde, QueryResponses}; +/// use cosmwasm_std::Addr; +/// +/// #[derive(Clone)] +/// #[proposal_module_query] +/// #[allow(dead_code)] +/// #[cw_serde] +/// #[derive(QueryResponses)] +/// enum Test { +/// Foo, +/// Bar(u64), +/// Baz { foo: u64 }, +/// } +/// ``` +#[proc_macro_attribute] +pub fn proposal_module_query(metadata: TokenStream, input: TokenStream) -> TokenStream { + let i = dao_interface_path("voting::InfoResponse"); + + merge_variants( + metadata, + input, + quote! { + enum Right { + /// Returns the address of the DAO this module belongs to + #[returns(::cosmwasm_std::Addr)] + Dao {}, + /// Returns contract version info + #[returns(#i)] + Info { }, + /// Returns the proposal ID that will be assigned to the + /// next proposal created. + #[returns(::std::primitive::u64)] + NextProposalId {}, + } + } + .into(), + ) +} + +/// Limits the number of variants allowed on an enum at compile +/// time. For example, the following will not compile: +/// +/// ```compile_fail +/// use dao_dao_macros::limit_variant_count; +/// +/// #[limit_variant_count(1)] +/// enum Two { +/// One {}, +/// Two {}, +/// } +/// ``` +#[proc_macro_attribute] +pub fn limit_variant_count(metadata: TokenStream, input: TokenStream) -> TokenStream { + let args = parse_macro_input!(metadata as AttributeArgs); + if args.len() != 1 { + panic!("macro takes one argument. ex: `#[limit_variant_count(4)]`") + } + + let limit: usize = if let syn::NestedMeta::Lit(syn::Lit::Int(unparsed)) = args.first().unwrap() + { + match unparsed.base10_parse() { + Ok(limit) => limit, + Err(e) => return e.to_compile_error().into(), + } + } else { + return syn::Error::new_spanned(args[0].clone(), "argument should be an integer literal") + .to_compile_error() + .into(); + }; + + let ast: DeriveInput = parse_macro_input!(input); + match ast.data { + syn::Data::Enum(DataEnum { ref variants, .. }) => { + if variants.len() > limit { + return syn::Error::new_spanned( + variants[limit].clone(), + format!("this enum's variant count is limited to {limit}"), + ) + .to_compile_error() + .into(); + } + } + _ => { + return syn::Error::new( + ast.ident.span(), + "limit_variant_count may only be derived for enums", + ) + .to_compile_error() + .into() + } + }; + + quote! { + #ast + } + .into() +} diff --git a/packages/dao-dao-macros/tests/govmod.rs b/packages/dao-dao-macros/tests/govmod.rs new file mode 100644 index 000000000..f69d281cb --- /dev/null +++ b/packages/dao-dao-macros/tests/govmod.rs @@ -0,0 +1,28 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +use dao_dao_macros::proposal_module_query; + +#[proposal_module_query] +#[allow(dead_code)] +#[cw_serde] +#[derive(QueryResponses)] +enum Test { + #[returns(String)] + Foo, + #[returns(String)] + Bar(u64), + #[returns(String)] + Baz { waldo: u64 }, +} + +#[test] +fn proposal_module_query_derive() { + let test = Test::Dao {}; + + // If this compiles we have won. + match test { + Test::Foo | Test::Bar(_) | Test::Baz { .. } | Test::Dao {} => "yay", + Test::Info {} => "yay", + Test::NextProposalId {} => "yay", + }; +} diff --git a/packages/dao-dao-macros/tests/voting.rs b/packages/dao-dao-macros/tests/voting.rs new file mode 100644 index 000000000..ad2816c82 --- /dev/null +++ b/packages/dao-dao-macros/tests/voting.rs @@ -0,0 +1,43 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +use dao_dao_macros::voting_module_query; + +/// enum for testing. Important that this derives things / has other +/// attributes so we can be sure we aren't messing with other macros +/// with ours. +#[voting_module_query] +#[allow(dead_code)] +#[cw_serde] +#[derive(QueryResponses)] +enum Test { + #[returns(String)] + Foo, + #[returns(String)] + Bar(u64), + #[returns(String)] + Baz { waldo: u64 }, +} + +#[test] +fn voting_module_query_derive() { + let _test = Test::VotingPowerAtHeight { + address: "foo".to_string(), + height: Some(10), + }; + + let test = Test::TotalPowerAtHeight { height: Some(10) }; + + // If this compiles we have won. + match test { + Test::Foo + | Test::Bar(_) + | Test::Baz { .. } + | Test::TotalPowerAtHeight { height: _ } + | Test::VotingPowerAtHeight { + height: _, + address: _, + } + | Test::Info {} => "yay", + Test::Dao {} => "yay", + }; +} diff --git a/packages/dao-interface/.cargo/config b/packages/dao-interface/.cargo/config new file mode 100644 index 000000000..e44e70f31 --- /dev/null +++ b/packages/dao-interface/.cargo/config @@ -0,0 +1,2 @@ +[alias] +schema = "run --example schema" diff --git a/packages/dao-interface/Cargo.toml b/packages/dao-interface/Cargo.toml new file mode 100644 index 000000000..c82abc5e4 --- /dev/null +++ b/packages/dao-interface/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dao-interface" +authors = ["ekez ekez@withoutdoing.com"] +description = "A package containing interface definitions for DAO DAO DAOs." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw721 = { workspace = true } +cw-hooks = { workspace = true } +cw-utils = { workspace = true } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } diff --git a/packages/dao-interface/README.md b/packages/dao-interface/README.md new file mode 100644 index 000000000..674b33b28 --- /dev/null +++ b/packages/dao-interface/README.md @@ -0,0 +1,4 @@ +# CosmWasm DAO Interface + +This package provides the types and interfaces needed for interacting +with DAO modules. diff --git a/packages/dao-interface/src/lib.rs b/packages/dao-interface/src/lib.rs new file mode 100644 index 000000000..f46f381c2 --- /dev/null +++ b/packages/dao-interface/src/lib.rs @@ -0,0 +1,8 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod migrate_msg; +pub mod msg; +pub mod proposal; +pub mod query; +pub mod state; +pub mod voting; diff --git a/packages/dao-interface/src/migrate_msg.rs b/packages/dao-interface/src/migrate_msg.rs new file mode 100644 index 000000000..a9175a379 --- /dev/null +++ b/packages/dao-interface/src/migrate_msg.rs @@ -0,0 +1,67 @@ +//! types used for migrating modules of the DAO with migrating core +//! copyo of the types from dao-migrator contract. + +use cosmwasm_schema::cw_serde; + +use crate::query::SubDao; +use crate::state::ModuleInstantiateInfo; + +#[cw_serde] +pub struct MigrateParams { + pub migrator_code_id: u64, + pub params: MigrateV1ToV2, +} + +#[cw_serde] +pub struct MigrateV1ToV2 { + pub sub_daos: Vec, + pub migration_params: MigrationModuleParams, + pub v1_code_ids: V1CodeIds, + pub v2_code_ids: V2CodeIds, +} + +// code ids for the v1 contracts +#[cw_serde] +pub struct V1CodeIds { + pub proposal_single: u64, + pub cw4_voting: u64, + pub cw20_stake: u64, + pub cw20_staked_balances_voting: u64, +} + +// code ids for the new contracts +#[cw_serde] +pub struct V2CodeIds { + pub proposal_single: u64, + pub cw4_voting: u64, + pub cw20_stake: u64, + pub cw20_staked_balances_voting: u64, +} + +/// The params we need to provide for migration msgs +#[cw_serde] +pub struct ProposalParams { + pub close_proposal_on_execution_failure: bool, + pub pre_propose_info: PreProposeInfo, +} + +#[cw_serde] +pub struct MigrationModuleParams { + // General + /// Rather or not to migrate the stake_cw20 contract and its + /// manager. If this is not set to true and a stake_cw20 + /// contract is detected in the DAO's configuration the + /// migration will be aborted. + pub migrate_stake_cw20_manager: Option, + // dao_proposal_single + pub proposal_params: Vec<(String, ProposalParams)>, +} + +#[cw_serde] +pub enum PreProposeInfo { + /// Anyone may create a proposal free of charge. + AnyoneMayPropose {}, + /// The module specified in INFO has exclusive rights to proposal + /// creation. + ModuleMayPropose { info: ModuleInstantiateInfo }, +} diff --git a/packages/dao-interface/src/msg.rs b/packages/dao-interface/src/msg.rs new file mode 100644 index 000000000..797865c2e --- /dev/null +++ b/packages/dao-interface/src/msg.rs @@ -0,0 +1,240 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Empty}; +use cw_utils::Duration; + +use crate::state::Config; +use crate::{migrate_msg::MigrateParams, query::SubDao, state::ModuleInstantiateInfo}; + +/// Information about an item to be stored in the items list. +#[cw_serde] +pub struct InitialItem { + /// The name of the item. + pub key: String, + /// The value the item will have at instantiation time. + pub value: String, +} + +#[cw_serde] +pub struct InstantiateMsg { + /// Optional Admin with the ability to execute DAO messages + /// directly. Useful for building SubDAOs controlled by a parent + /// DAO. If no admin is specified the contract is set as its own + /// admin so that the admin may be updated later by governance. + pub admin: Option, + /// The name of the core contract. + pub name: String, + /// A description of the core contract. + pub description: String, + /// An image URL to describe the core module contract. + pub image_url: Option, + + /// If true the contract will automatically add received cw20 + /// tokens to its treasury. + pub automatically_add_cw20s: bool, + /// If true the contract will automatically add received cw721 + /// tokens to its treasury. + pub automatically_add_cw721s: bool, + + /// Instantiate information for the core contract's voting + /// power module. + pub voting_module_instantiate_info: ModuleInstantiateInfo, + /// Instantiate information for the core contract's proposal modules. + /// NOTE: the pre-propose-base package depends on it being the case + /// that the core module instantiates its proposal module. + pub proposal_modules_instantiate_info: Vec, + + /// The items to instantiate this DAO with. Items are arbitrary + /// key-value pairs whose contents are controlled by governance. + /// + /// It is an error to provide two items with the same key. + pub initial_items: Option>, + /// Implements the DAO Star standard: + pub dao_uri: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Callable by the Admin, if one is configured. + /// Executes messages in order. + ExecuteAdminMsgs { msgs: Vec> }, + /// Callable by proposal modules. The DAO will execute the + /// messages in the hook in order. + ExecuteProposalHook { msgs: Vec> }, + /// Pauses the DAO for a set duration. + /// When paused the DAO is unable to execute proposals + Pause { duration: Duration }, + /// Executed when the contract receives a cw20 token. Depending on + /// the contract's configuration the contract will automatically + /// add the token to its treasury. + Receive(cw20::Cw20ReceiveMsg), + /// Executed when the contract receives a cw721 token. Depending + /// on the contract's configuration the contract will + /// automatically add the token to its treasury. + ReceiveNft(cw721::Cw721ReceiveMsg), + /// Removes an item from the governance contract's item map. + RemoveItem { key: String }, + /// Adds an item to the governance contract's item map. If the + /// item already exists the existing value is overridden. If the + /// item does not exist a new item is added. + SetItem { key: String, value: String }, + /// Callable by the admin of the contract. If ADMIN is None the + /// admin is set as the contract itself so that it may be updated + /// later by vote. If ADMIN is Some a new admin is proposed and + /// that new admin may become the admin by executing the + /// `AcceptAdminNomination` message. + /// + /// If there is already a pending admin nomination the + /// `WithdrawAdminNomination` message must be executed before a + /// new admin may be nominated. + NominateAdmin { admin: Option }, + /// Callable by a nominated admin. Admins are nominated via the + /// `NominateAdmin` message. Accepting a nomination will make the + /// nominated address the new admin. + /// + /// Requiring that the new admin accepts the nomination before + /// becoming the admin protects against a typo causing the admin + /// to change to an invalid address. + AcceptAdminNomination {}, + /// Callable by the current admin. Withdraws the current admin + /// nomination. + WithdrawAdminNomination {}, + /// Callable by the core contract. Replaces the current + /// governance contract config with the provided config. + UpdateConfig { config: Config }, + /// Updates the list of cw20 tokens this contract has registered. + UpdateCw20List { + to_add: Vec, + to_remove: Vec, + }, + /// Updates the list of cw721 tokens this contract has registered. + UpdateCw721List { + to_add: Vec, + to_remove: Vec, + }, + /// Updates the governance contract's governance modules. Module + /// instantiate info in `to_add` is used to create new modules and + /// install them. + UpdateProposalModules { + /// NOTE: the pre-propose-base package depends on it being the + /// case that the core module instantiates its proposal module. + to_add: Vec, + to_disable: Vec, + }, + /// Callable by the core contract. Replaces the current + /// voting module with a new one instantiated by the governance + /// contract. + UpdateVotingModule { module: ModuleInstantiateInfo }, + /// Update the core module to add/remove SubDAOs and their charters + UpdateSubDaos { + to_add: Vec, + to_remove: Vec, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Get's the DAO's admin. Returns `Addr`. + #[returns(cosmwasm_std::Addr)] + Admin {}, + /// Get's the currently nominated admin (if any). + #[returns(crate::query::AdminNominationResponse)] + AdminNomination {}, + /// Gets the contract's config. + #[returns(Config)] + Config {}, + /// Gets the token balance for each cw20 registered with the + /// contract. + #[returns(crate::query::Cw20BalanceResponse)] + Cw20Balances { + start_after: Option, + limit: Option, + }, + /// Lists the addresses of the cw20 tokens in this contract's + /// treasury. + #[returns(Vec)] + Cw20TokenList { + start_after: Option, + limit: Option, + }, + /// Lists the addresses of the cw721 tokens in this contract's + /// treasury. + #[returns(Vec)] + Cw721TokenList { + start_after: Option, + limit: Option, + }, + /// Dumps all of the core contract's state in a single + /// query. Useful for frontends as performance for queries is more + /// limited by network times than compute times. + #[returns(crate::query::DumpStateResponse)] + DumpState {}, + /// Gets the address associated with an item key. + #[returns(crate::query::GetItemResponse)] + GetItem { key: String }, + /// Lists all of the items associted with the contract. For + /// example, given the items `{ "group": "foo", "subdao": "bar"}` + /// this query would return `[("group", "foo"), ("subdao", + /// "bar")]`. + #[returns(Vec)] + ListItems { + start_after: Option, + limit: Option, + }, + /// Returns contract version info + #[returns(crate::voting::InfoResponse)] + Info {}, + /// Gets all proposal modules associated with the + /// contract. + #[returns(Vec)] + ProposalModules { + start_after: Option, + limit: Option, + }, + /// Gets the active proposal modules associated with the + /// contract. + #[returns(Vec)] + ActiveProposalModules { + start_after: Option, + limit: Option, + }, + /// Gets the number of active and total proposal modules + /// registered with this module. + #[returns(crate::query::ProposalModuleCountResponse)] + ProposalModuleCount {}, + /// Returns information about if the contract is currently paused. + #[returns(crate::query::PauseInfoResponse)] + PauseInfo {}, + /// Gets the contract's voting module. + #[returns(cosmwasm_std::Addr)] + VotingModule {}, + /// Returns all SubDAOs with their charters in a vec. + /// start_after is bound exclusive and asks for a string address. + #[returns(Vec)] + ListSubDaos { + start_after: Option, + limit: Option, + }, + /// Implements the DAO Star standard: + #[returns(crate::query::DaoURIResponse)] + DaoURI {}, + /// Returns the voting power for an address at a given height. + #[returns(crate::voting::VotingPowerAtHeightResponse)] + VotingPowerAtHeight { + address: String, + height: Option, + }, + /// Returns the total voting power at a given block height. + #[returns(crate::voting::TotalPowerAtHeightResponse)] + TotalPowerAtHeight { height: Option }, +} + +#[allow(clippy::large_enum_variant)] +#[cw_serde] +pub enum MigrateMsg { + FromV1 { + dao_uri: Option, + params: Option, + }, + FromCompatible {}, +} diff --git a/packages/dao-interface/src/proposal.rs b/packages/dao-interface/src/proposal.rs new file mode 100644 index 000000000..77076d5c2 --- /dev/null +++ b/packages/dao-interface/src/proposal.rs @@ -0,0 +1,39 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cw2::ContractVersion; + +#[cw_serde] +pub struct InfoResponse { + pub info: ContractVersion, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum Query { + /// Returns the address of the DAO this module belongs to + #[returns(::cosmwasm_std::Addr)] + Dao {}, + /// Returns contract version info + #[returns(InfoResponse)] + Info {}, + /// Returns the proposal ID that will be assigned to the + /// next proposal created. + #[returns(::std::primitive::u64)] + NextProposalId {}, +} + +mod tests { + /// Make sure the enum has all of the fields we expect. This will + /// fail to compile if not. + #[test] + fn test_macro_expansion() { + use super::Query; + + let query = Query::Info {}; + + match query { + Query::Dao {} => (), + Query::Info {} => (), + Query::NextProposalId {} => (), + } + } +} diff --git a/packages/dao-interface/src/query.rs b/packages/dao-interface/src/query.rs new file mode 100644 index 000000000..054c97e02 --- /dev/null +++ b/packages/dao-interface/src/query.rs @@ -0,0 +1,82 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw2::ContractVersion; +use cw_utils::Expiration; + +use crate::state::{Config, ProposalModule}; + +/// Relevant state for the governance module. Returned by the +/// `DumpState` query. +#[cw_serde] +pub struct DumpStateResponse { + /// Optional DAO Admin + pub admin: Addr, + /// The governance contract's config. + pub config: Config, + // True if the contract is currently paused. + pub pause_info: PauseInfoResponse, + /// The governance contract's version. + pub version: ContractVersion, + /// The governance modules associated with the governance + /// contract. + pub proposal_modules: Vec, + /// The voting module associated with the governance contract. + pub voting_module: Addr, + /// The number of active proposal modules. + pub active_proposal_module_count: u32, + /// The total number of proposal modules. + pub total_proposal_module_count: u32, +} + +/// Information about if the contract is currently paused. +#[cw_serde] +pub enum PauseInfoResponse { + Paused { expiration: Expiration }, + Unpaused {}, +} + +/// Returned by the `GetItem` query. +#[cw_serde] +pub struct GetItemResponse { + /// `None` if no item with the provided key was found, `Some` + /// otherwise. + pub item: Option, +} + +/// Returned by the `Cw20Balances` query. +#[cw_serde] +pub struct Cw20BalanceResponse { + /// The address of the token. + pub addr: Addr, + /// The contract's balance. + pub balance: Uint128, +} + +/// Returned by the `AdminNomination` query. +#[cw_serde] +pub struct AdminNominationResponse { + /// The currently nominated admin or None if no nomination is + /// pending. + pub nomination: Option, +} + +#[cw_serde] +pub struct SubDao { + /// The contract address of the SubDAO + pub addr: String, + /// The purpose/constitution for the SubDAO + pub charter: Option, +} + +#[cw_serde] +pub struct DaoURIResponse { + pub dao_uri: Option, +} + +#[cw_serde] +pub struct ProposalModuleCountResponse { + /// The number of active proposal modules. + pub active_proposal_module_count: u32, + /// The total number of proposal modules. + pub total_proposal_module_count: u32, +} diff --git a/packages/dao-interface/src/state.rs b/packages/dao-interface/src/state.rs new file mode 100644 index 000000000..3a0841f9b --- /dev/null +++ b/packages/dao-interface/src/state.rs @@ -0,0 +1,155 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Binary, CosmosMsg, WasmMsg}; + +/// Top level config type for core module. +#[cw_serde] +pub struct Config { + /// The name of the contract. + pub name: String, + /// A description of the contract. + pub description: String, + /// An optional image URL for displaying alongside the contract. + pub image_url: Option, + /// If true the contract will automatically add received cw20 + /// tokens to its treasury. + pub automatically_add_cw20s: bool, + /// If true the contract will automatically add received cw721 + /// tokens to its treasury. + pub automatically_add_cw721s: bool, + /// The URI for the DAO as defined by the DAOstar standard + /// + pub dao_uri: Option, +} + +/// Top level type describing a proposal module. +#[cw_serde] +pub struct ProposalModule { + /// The address of the proposal module. + pub address: Addr, + /// The URL prefix of this proposal module as derived from the module ID. + /// Prefixes are mapped to letters, e.g. 0 is 'A', and 26 is 'AA'. + pub prefix: String, + /// The status of the proposal module, e.g. 'Enabled' or 'Disabled.' + pub status: ProposalModuleStatus, +} + +/// The status of a proposal module. +#[cw_serde] +pub enum ProposalModuleStatus { + Enabled, + Disabled, +} + +/// Information about the CosmWasm level admin of a contract. Used in +/// conjunction with `ModuleInstantiateInfo` to instantiate modules. +#[cw_serde] +pub enum Admin { + /// Set the admin to a specified address. + Address { addr: String }, + /// Sets the admin as the core module address. + CoreModule {}, +} + +/// Information needed to instantiate a module. +#[cw_serde] +pub struct ModuleInstantiateInfo { + /// Code ID of the contract to be instantiated. + pub code_id: u64, + /// Instantiate message to be used to create the contract. + pub msg: Binary, + /// CosmWasm level admin of the instantiated contract. See: + /// + pub admin: Option, + /// Label for the instantiated contract. + pub label: String, +} + +impl ModuleInstantiateInfo { + pub fn into_wasm_msg(self, dao: Addr) -> WasmMsg { + WasmMsg::Instantiate { + admin: self.admin.map(|admin| match admin { + Admin::Address { addr } => addr, + Admin::CoreModule {} => dao.into_string(), + }), + code_id: self.code_id, + msg: self.msg, + funds: vec![], + label: self.label, + } + } +} + +/// Callbacks to be executed when a module is instantiated +#[cw_serde] +pub struct ModuleInstantiateCallback { + pub msgs: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + use cosmwasm_std::{to_binary, Addr, WasmMsg}; + + #[test] + fn test_module_instantiate_admin_none() { + let no_admin = ModuleInstantiateInfo { + code_id: 42, + msg: to_binary("foo").unwrap(), + admin: None, + label: "bar".to_string(), + }; + assert_eq!( + no_admin.into_wasm_msg(Addr::unchecked("ekez")), + WasmMsg::Instantiate { + admin: None, + code_id: 42, + msg: to_binary("foo").unwrap(), + funds: vec![], + label: "bar".to_string() + } + ) + } + + #[test] + fn test_module_instantiate_admin_addr() { + let no_admin = ModuleInstantiateInfo { + code_id: 42, + msg: to_binary("foo").unwrap(), + admin: Some(Admin::Address { + addr: "core".to_string(), + }), + label: "bar".to_string(), + }; + assert_eq!( + no_admin.into_wasm_msg(Addr::unchecked("ekez")), + WasmMsg::Instantiate { + admin: Some("core".to_string()), + code_id: 42, + msg: to_binary("foo").unwrap(), + funds: vec![], + label: "bar".to_string() + } + ) + } + + #[test] + fn test_module_instantiate_instantiator_addr() { + let no_admin = ModuleInstantiateInfo { + code_id: 42, + msg: to_binary("foo").unwrap(), + admin: Some(Admin::CoreModule {}), + label: "bar".to_string(), + }; + assert_eq!( + no_admin.into_wasm_msg(Addr::unchecked("ekez")), + WasmMsg::Instantiate { + admin: Some("ekez".to_string()), + code_id: 42, + msg: to_binary("foo").unwrap(), + funds: vec![], + label: "bar".to_string() + } + ) + } +} diff --git a/packages/dao-interface/src/voting.rs b/packages/dao-interface/src/voting.rs new file mode 100644 index 000000000..f2df11a44 --- /dev/null +++ b/packages/dao-interface/src/voting.rs @@ -0,0 +1,53 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; +use cw2::ContractVersion; + +#[cw_serde] +#[derive(QueryResponses)] +pub enum Query { + /// Returns the token contract address, if set. + #[returns(::cosmwasm_std::Addr)] + TokenContract {}, + /// Returns the voting power for an address at a given height. + #[returns(VotingPowerAtHeightResponse)] + VotingPowerAtHeight { + address: ::std::string::String, + height: ::std::option::Option<::std::primitive::u64>, + }, + /// Returns the total voting power at a given block heigh. + #[returns(TotalPowerAtHeightResponse)] + TotalPowerAtHeight { + height: ::std::option::Option<::std::primitive::u64>, + }, + /// Returns the address of the DAO this module belongs to. + #[returns(cosmwasm_std::Addr)] + Dao {}, + /// Returns contract version info. + #[returns(InfoResponse)] + Info {}, + /// Whether the DAO is active or not. + #[returns(::std::primitive::bool)] + IsActive {}, +} + +#[cw_serde] +pub struct VotingPowerAtHeightResponse { + pub power: Uint128, + pub height: u64, +} + +#[cw_serde] +pub struct TotalPowerAtHeightResponse { + pub power: Uint128, + pub height: u64, +} + +#[cw_serde] +pub struct InfoResponse { + pub info: ContractVersion, +} + +#[cw_serde] +pub struct IsActiveResponse { + pub active: bool, +} diff --git a/packages/dao-pre-propose-base/.cargo/config b/packages/dao-pre-propose-base/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/packages/dao-pre-propose-base/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/packages/dao-pre-propose-base/Cargo.toml b/packages/dao-pre-propose-base/Cargo.toml new file mode 100644 index 000000000..ebc864973 --- /dev/null +++ b/packages/dao-pre-propose-base/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "dao-pre-propose-base" +authors = ["ekez ekez@withoutdoing.com"] +description = "A package for implementing pre-propose modules." +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 WASM exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw2 = { workspace = true } +cw-denom = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw-hooks = { workspace = true } +dao-proposal-hooks = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/packages/dao-pre-propose-base/README.md b/packages/dao-pre-propose-base/README.md new file mode 100644 index 000000000..025f8026e --- /dev/null +++ b/packages/dao-pre-propose-base/README.md @@ -0,0 +1,13 @@ +# Pre-Propose Base + +This provides a base package that may be used to implement [pre-propose +modules](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#pre-propose-modules). +It is modeled after the +[cw721-base](https://github.com/CosmWasm/cw-nfts/tree/27ffdc6c24c2d173be6c677d04bec1420191184d/contracts/cw721-base) +contract for implementing NFT contracts. + +See the [pre-propose-single](../../contracts/pre-propose/dao-pre-propose-single) +contract for an example of using this package to implement a proposal +module with deposits. + +Our wiki has more info on [pre-propose module design](https://github.com/DA0-DA0/dao-contracts/wiki/Pre-propose-module-design). diff --git a/packages/dao-pre-propose-base/src/error.rs b/packages/dao-pre-propose-base/src/error.rs new file mode 100644 index 000000000..8ccfbb140 --- /dev/null +++ b/packages/dao-pre-propose-base/src/error.rs @@ -0,0 +1,52 @@ +use cosmwasm_std::StdError; +use cw_denom::DenomError; +use cw_utils::ParseReplyError; +use thiserror::Error; + +use cw_hooks::HookError; +use dao_voting::{deposit::DepositError, status::Status}; + +#[derive(Error, Debug, PartialEq)] +pub enum PreProposeError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Denom(#[from] DenomError), + + #[error(transparent)] + Deposit(#[from] DepositError), + + #[error(transparent)] + Hooks(#[from] HookError), + + #[error(transparent)] + ParseReplyError(#[from] ParseReplyError), + + #[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 {}, + + #[error("Nothing to withdraw")] + NothingToWithdraw {}, + + #[error("Proposal status ({status}) not closed or executed")] + NotClosedOrExecuted { status: Status }, + + #[error("Proposal not found")] + ProposalNotFound {}, + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("An unknown reply ID was received.")] + UnknownReplyID {}, +} diff --git a/packages/dao-pre-propose-base/src/execute.rs b/packages/dao-pre-propose-base/src/execute.rs new file mode 100644 index 000000000..225a465fe --- /dev/null +++ b/packages/dao-pre-propose-base/src/execute.rs @@ -0,0 +1,368 @@ +use cosmwasm_schema::schemars::JsonSchema; +use cosmwasm_std::{ + to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult, SubMsg, WasmMsg, +}; + +use cw2::set_contract_version; + +use cw_denom::UncheckedDenom; +use dao_interface::voting::{Query as CwCoreQuery, VotingPowerAtHeightResponse}; +use dao_voting::{ + deposit::{DepositRefundPolicy, UncheckedDepositInfo}, + status::Status, +}; +use serde::Serialize; + +use crate::{ + error::PreProposeError, + msg::{DepositInfoResponse, ExecuteMsg, InstantiateMsg, QueryMsg}, + state::{Config, PreProposeContract}, +}; + +const CONTRACT_NAME: &str = "crates.io::dao-pre-propose-base"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +impl + PreProposeContract +where + ProposalMessage: Serialize, + QueryExt: JsonSchema, +{ + pub fn instantiate( + &self, + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, + ) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // The proposal module instantiates us. We're + // making limited assumptions here. The only way to associate + // a deposit module with a proposal module is for the proposal + // module to instantiate it. + self.proposal_module.save(deps.storage, &info.sender)?; + + // Query the proposal module for its DAO. + let dao: Addr = deps + .querier + .query_wasm_smart(info.sender.clone(), &CwCoreQuery::Dao {})?; + + self.dao.save(deps.storage, &dao)?; + + let deposit_info = msg + .deposit_info + .map(|info| info.into_checked(deps.as_ref(), dao.clone())) + .transpose()?; + + let config = Config { + deposit_info, + open_proposal_submission: msg.open_proposal_submission, + }; + + self.config.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("method", "instantiate") + .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(), + ) + .add_attribute("dao", dao)) + } + + pub fn execute( + &self, + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, + ) -> Result { + match msg { + 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), + ExecuteMsg::Withdraw { denom } => { + self.execute_withdraw(deps.as_ref(), env, info, denom) + } + ExecuteMsg::AddProposalSubmittedHook { address } => { + self.execute_add_proposal_submitted_hook(deps, info, address) + } + ExecuteMsg::RemoveProposalSubmittedHook { address } => { + self.execute_remove_proposal_submitted_hook(deps, info, address) + } + ExecuteMsg::ProposalCompletedHook { + proposal_id, + new_status, + } => self.execute_proposal_completed_hook(deps.as_ref(), info, proposal_id, new_status), + + ExecuteMsg::Extension { .. } => Ok(Response::default()), + } + } + + pub fn execute_propose( + &self, + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ProposalMessage, + ) -> Result { + self.check_can_submit(deps.as_ref(), info.sender.clone())?; + + let config = self.config.load(deps.storage)?; + + let deposit_messages = if let Some(ref deposit_info) = config.deposit_info { + deposit_info.check_native_deposit_paid(&info)?; + deposit_info.get_take_deposit_messages(&info.sender, &env.contract.address)? + } else { + vec![] + }; + + let proposal_module = self.proposal_module.load(deps.storage)?; + + // Snapshot the deposit using the ID of the proposal that we + // will create. + let next_id = deps.querier.query_wasm_smart( + &proposal_module, + &dao_interface::proposal::Query::NextProposalId {}, + )?; + self.deposits.save( + deps.storage, + next_id, + &(config.deposit_info, info.sender.clone()), + )?; + + let propose_messsage = WasmMsg::Execute { + contract_addr: proposal_module.into_string(), + msg: to_binary(&msg)?, + funds: vec![], + }; + + let hooks_msgs = self + .proposal_submitted_hooks + .prepare_hooks(deps.storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.into_string(), + msg: to_binary(&msg)?, + funds: vec![], + }; + Ok(SubMsg::new(execute)) + })?; + + Ok(Response::default() + .add_attribute("method", "execute_propose") + .add_attribute("sender", info.sender) + // It's important that the propose message is + // first. Otherwise, a hook receiver could create a + // proposal before us and invalidate our `NextProposalId + // {}` query. + .add_message(propose_messsage) + .add_submessages(hooks_msgs) + .add_messages(deposit_messages)) + } + + pub fn execute_update_config( + &self, + deps: DepsMut, + info: MessageInfo, + deposit_info: Option, + open_proposal_submission: bool, + ) -> 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 { + deposit_info, + open_proposal_submission, + }, + )?; + + Ok(Response::default() + .add_attribute("method", "update_config") + .add_attribute("sender", info.sender)) + } + } + + pub fn execute_withdraw( + &self, + deps: Deps, + env: Env, + info: MessageInfo, + denom: Option, + ) -> Result { + let dao = self.dao.load(deps.storage)?; + if info.sender != dao { + Err(PreProposeError::NotDao {}) + } else { + let denom = match denom { + Some(denom) => Some(denom.into_checked(deps)?), + None => { + let config = self.config.load(deps.storage)?; + config.deposit_info.map(|d| d.denom) + } + }; + match denom { + None => Err(PreProposeError::NoWithdrawalDenom {}), + Some(denom) => { + let balance = denom.query_balance(&deps.querier, &env.contract.address)?; + if balance.is_zero() { + Err(PreProposeError::NothingToWithdraw {}) + } else { + let withdraw_message = denom.get_transfer_to_message(&dao, balance)?; + Ok(Response::default() + .add_message(withdraw_message) + .add_attribute("method", "withdraw") + .add_attribute("receiver", &dao) + .add_attribute("denom", denom.to_string())) + } + } + } + } + } + + pub fn execute_add_proposal_submitted_hook( + &self, + deps: DepsMut, + info: MessageInfo, + address: String, + ) -> Result { + let dao = self.dao.load(deps.storage)?; + if info.sender != dao { + return Err(PreProposeError::NotDao {}); + } + + let addr = deps.api.addr_validate(&address)?; + self.proposal_submitted_hooks.add_hook(deps.storage, addr)?; + + Ok(Response::default()) + } + + pub fn execute_remove_proposal_submitted_hook( + &self, + deps: DepsMut, + info: MessageInfo, + address: String, + ) -> Result { + let dao = self.dao.load(deps.storage)?; + if info.sender != dao { + return Err(PreProposeError::NotDao {}); + } + + // Validate address + let addr = deps.api.addr_validate(&address)?; + + // Remove the hook + self.proposal_submitted_hooks + .remove_hook(deps.storage, addr)?; + + Ok(Response::default()) + } + + pub fn execute_proposal_completed_hook( + &self, + deps: Deps, + info: MessageInfo, + id: u64, + new_status: Status, + ) -> Result { + let proposal_module = self.proposal_module.load(deps.storage)?; + if info.sender != proposal_module { + return Err(PreProposeError::NotModule {}); + } + + // If we receive a proposal completed hook from a proposal + // module, and it is not in one of these states, something + // bizare has happened. In that event, this message errors + // which ought to cause the proposal module to remove this + // module and open proposal submission to anyone. + if new_status != Status::Closed && new_status != Status::Executed { + return Err(PreProposeError::NotClosedOrExecuted { status: new_status }); + } + + match self.deposits.may_load(deps.storage, id)? { + Some((deposit_info, proposer)) => { + let messages = if let Some(ref deposit_info) = deposit_info { + // Refund can be issued if proposal if it is going to + // closed or executed. + let should_refund_to_proposer = (new_status == Status::Closed + && deposit_info.refund_policy == DepositRefundPolicy::Always) + || (new_status == Status::Executed + && deposit_info.refund_policy != DepositRefundPolicy::Never); + + if should_refund_to_proposer { + deposit_info.get_return_deposit_message(&proposer)? + } else { + // If the proposer doesn't get the deposit, the DAO does. + let dao = self.dao.load(deps.storage)?; + deposit_info.get_return_deposit_message(&dao)? + } + } else { + // No deposit info for this proposal. Nothing to do. + vec![] + }; + + Ok(Response::default() + .add_attribute("method", "execute_proposal_completed_hook") + .add_attribute("proposal", id.to_string()) + .add_attribute("deposit_info", to_binary(&deposit_info)?.to_string()) + .add_messages(messages)) + } + + // If we do not have a deposit for this proposal it was + // likely created before we were added to the proposal + // module. In that case, it's not our problem and we just + // do nothing. + None => Ok(Response::default() + .add_attribute("method", "execute_proposal_completed_hook") + .add_attribute("proposal", id.to_string())), + } + } + + 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 {}); + } + } + Ok(()) + } + + pub fn query(&self, deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::ProposalModule {} => to_binary(&self.proposal_module.load(deps.storage)?), + QueryMsg::Dao {} => to_binary(&self.dao.load(deps.storage)?), + QueryMsg::Config {} => to_binary(&self.config.load(deps.storage)?), + QueryMsg::DepositInfo { proposal_id } => { + let (deposit_info, proposer) = self.deposits.load(deps.storage, proposal_id)?; + to_binary(&DepositInfoResponse { + deposit_info, + proposer, + }) + } + QueryMsg::ProposalSubmittedHooks {} => { + to_binary(&self.proposal_submitted_hooks.query_hooks(deps)?) + } + QueryMsg::QueryExtension { .. } => Ok(Binary::default()), + } + } +} diff --git a/packages/dao-pre-propose-base/src/lib.rs b/packages/dao-pre-propose-base/src/lib.rs new file mode 100644 index 000000000..be0040039 --- /dev/null +++ b/packages/dao-pre-propose-base/src/lib.rs @@ -0,0 +1,9 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod error; +pub mod execute; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; diff --git a/packages/dao-pre-propose-base/src/msg.rs b/packages/dao-pre-propose-base/src/msg.rs new file mode 100644 index 000000000..b2f2cc410 --- /dev/null +++ b/packages/dao-pre-propose-base/src/msg.rs @@ -0,0 +1,127 @@ +use cosmwasm_schema::{cw_serde, schemars::JsonSchema, QueryResponses}; +use cw_denom::UncheckedDenom; +use dao_voting::{ + deposit::{CheckedDepositInfo, UncheckedDepositInfo}, + status::Status, +}; + +#[cw_serde] +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, + /// Extension for instantiation. The default implementation will + /// do nothing with this data. + pub extension: InstantiateExt, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Creates a new proposal in the pre-propose module. MSG will be + /// serialized and used as the proposal creation message. + Propose { msg: ProposalMessage }, + + /// Updates the configuration of this module. This will completely + /// override the existing configuration. This new configuration + /// will only apply to proposals created after the config is + /// updated. Only the DAO may execute this message. + UpdateConfig { + deposit_info: Option, + open_proposal_submission: bool, + }, + + /// Withdraws funds inside of this contract to the message + /// sender. The contracts entire balance for the specifed DENOM is + /// withdrawn to the message sender. Only the DAO may call this + /// method. + /// + /// This is intended only as an escape hatch in the event of a + /// critical bug in this contract or it's proposal + /// module. Withdrawing funds will cause future attempts to return + /// proposal deposits to fail their transactions as the contract + /// will have insufficent balance to return them. In the case of + /// `cw-proposal-single` this transaction failure will cause the + /// module to remove the pre-propose module from its proposal hook + /// receivers. + /// + /// More likely than not, this should NEVER BE CALLED unless a bug + /// in this contract or the proposal module it is associated with + /// has caused it to stop receiving proposal hook messages, or if + /// a critical security vulnerability has been found that allows + /// an attacker to drain proposal deposits. + Withdraw { + /// The denom to withdraw funds for. If no denom is specified, + /// the denomination currently configured for proposal + /// deposits will be used. + /// + /// You may want to specify a denomination here if you are + /// withdrawing funds that were previously accepted for + /// proposal deposits but are not longer used due to an + /// `UpdateConfig` message being executed on the contract. + denom: Option, + }, + + /// Extension message. Contracts that extend this one should put + /// their custom execute logic here. The default implementation + /// will do nothing if this variant is executed. + Extension { msg: ExecuteExt }, + + /// Adds a proposal submitted hook. Fires when a new proposal is submitted + /// to the pre-propose contract. Only the DAO may call this method. + AddProposalSubmittedHook { address: String }, + + /// Removes a proposal submitted hook. Only the DAO may call this method. + RemoveProposalSubmittedHook { address: String }, + + /// Handles proposal hook fired by the associated proposal + /// module when a proposal is completed (ie executed or rejected). + /// By default, the base contract will return deposits + /// proposals, when they are closed, when proposals are executed, or, + /// if it is refunding failed. + ProposalCompletedHook { + proposal_id: u64, + new_status: Status, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg +where + QueryExt: JsonSchema, +{ + /// Gets the proposal module that this pre propose module is + /// associated with. Returns `Addr`. + #[returns(cosmwasm_std::Addr)] + ProposalModule {}, + /// Gets the DAO (dao-dao-core) module this contract is associated + /// with. Returns `Addr`. + #[returns(cosmwasm_std::Addr)] + Dao {}, + /// Gets the module's configuration. + #[returns(crate::state::Config)] + Config {}, + /// Gets the deposit info for the proposal identified by + /// PROPOSAL_ID. + #[returns(DepositInfoResponse)] + DepositInfo { proposal_id: u64 }, + /// Returns list of proposal submitted hooks. + #[returns(cw_hooks::HooksResponse)] + ProposalSubmittedHooks {}, + /// Extension for queries. The default implementation will do + /// nothing if queried for will return `Binary::default()`. + #[returns(cosmwasm_std::Binary)] + QueryExtension { msg: QueryExt }, +} + +#[cw_serde] +pub struct DepositInfoResponse { + /// The deposit that has been paid for the specified proposal. + pub deposit_info: Option, + /// The address that created the proposal. + pub proposer: cosmwasm_std::Addr, +} diff --git a/packages/dao-pre-propose-base/src/state.rs b/packages/dao-pre-propose-base/src/state.rs new file mode 100644 index 000000000..26310a5cb --- /dev/null +++ b/packages/dao-pre-propose-base/src/state.rs @@ -0,0 +1,82 @@ +use std::marker::PhantomData; + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_hooks::Hooks; +use cw_storage_plus::{Item, Map}; + +use dao_voting::deposit::CheckedDepositInfo; + +#[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, +} + +pub struct PreProposeContract { + /// The proposal module that this module is associated with. + pub proposal_module: Item<'static, Addr>, + /// The DAO (dao-dao-core module) that this module is associated + /// with. + pub dao: Item<'static, Addr>, + /// The configuration for this module. + pub config: Item<'static, Config>, + /// Map between proposal IDs and (deposit, proposer) pairs. + pub deposits: Map<'static, u64, (Option, Addr)>, + /// Consumers of proposal submitted hooks. + pub proposal_submitted_hooks: Hooks<'static>, + + // These types are used in associated functions, but not + // assocaited data. To stop the compiler complaining about unused + // generics, we build this phantom data. + instantiate_type: PhantomData, + execute_type: PhantomData, + query_type: PhantomData, + proposal_type: PhantomData, +} + +impl + PreProposeContract +{ + const fn new( + proposal_key: &'static str, + dao_key: &'static str, + config_key: &'static str, + deposits_key: &'static str, + proposal_submitted_hooks_key: &'static str, + ) -> Self { + Self { + proposal_module: Item::new(proposal_key), + dao: Item::new(dao_key), + config: Item::new(config_key), + deposits: Map::new(deposits_key), + proposal_submitted_hooks: Hooks::new(proposal_submitted_hooks_key), + execute_type: PhantomData, + instantiate_type: PhantomData, + query_type: PhantomData, + proposal_type: PhantomData, + } + } +} + +impl Default + for PreProposeContract +{ + fn default() -> Self { + // Call into constant function here. Presumably, the compiler + // is clever enough to inline this. This gives us + // "more-or-less" constant evaluation for our default method. + Self::new( + "proposal_module", + "dao", + "config", + "deposits", + "proposal_submitted_hooks", + ) + } +} diff --git a/packages/dao-pre-propose-base/src/tests.rs b/packages/dao-pre-propose-base/src/tests.rs new file mode 100644 index 000000000..f04c6ed5a --- /dev/null +++ b/packages/dao-pre-propose-base/src/tests.rs @@ -0,0 +1,202 @@ +use cosmwasm_std::{ + from_binary, + testing::{mock_dependencies, mock_env, mock_info}, + to_binary, Addr, Binary, ContractResult, Empty, Response, SubMsg, WasmMsg, +}; +use cw_hooks::HooksResponse; +use dao_voting::status::Status; + +use crate::{ + error::PreProposeError, + msg::{ExecuteMsg, QueryMsg}, + state::{Config, PreProposeContract}, +}; + +type Contract = PreProposeContract; + +#[test] +fn test_completed_hook_status_invariant() { + let mut deps = mock_dependencies(); + let info = mock_info("pm", &[]); + + let module = Contract::default(); + + module + .proposal_module + .save(&mut deps.storage, &Addr::unchecked("pm")) + .unwrap(); + + let res = module.execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ProposalCompletedHook { + proposal_id: 1, + new_status: Status::Passed, + }, + ); + + assert_eq!( + res.unwrap_err(), + PreProposeError::NotClosedOrExecuted { + status: Status::Passed + } + ); +} + +#[test] +fn test_completed_hook_auth() { + let mut deps = mock_dependencies(); + let info = mock_info("evil", &[]); + let module = Contract::default(); + + module + .proposal_module + .save(&mut deps.storage, &Addr::unchecked("pm")) + .unwrap(); + + let res = module.execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::ProposalCompletedHook { + proposal_id: 1, + new_status: Status::Passed, + }, + ); + + assert_eq!(res.unwrap_err(), PreProposeError::NotModule {}); +} + +#[test] +fn test_proposal_submitted_hooks() { + let mut deps = mock_dependencies(); + let module = Contract::default(); + + module + .dao + .save(&mut deps.storage, &Addr::unchecked("d")) + .unwrap(); + module + .proposal_module + .save(&mut deps.storage, &Addr::unchecked("pm")) + .unwrap(); + module + .config + .save( + &mut deps.storage, + &Config { + deposit_info: None, + open_proposal_submission: true, + }, + ) + .unwrap(); + + // The DAO can add a hook. + let info = mock_info("d", &[]); + module + .execute_add_proposal_submitted_hook(deps.as_mut(), info, "one".to_string()) + .unwrap(); + let hooks: HooksResponse = from_binary( + &module + .query( + deps.as_ref(), + mock_env(), + QueryMsg::ProposalSubmittedHooks {}, + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(hooks.hooks, vec!["one".to_string()]); + + // Non-DAO addresses can not add hooks. + let info = mock_info("n", &[]); + let err = module + .execute_add_proposal_submitted_hook(deps.as_mut(), info, "two".to_string()) + .unwrap_err(); + assert_eq!(err, PreProposeError::NotDao {}); + + deps.querier.update_wasm(|_| { + // for responding to the next proposal ID query that gets fired by propose. + cosmwasm_std::SystemResult::Ok(ContractResult::Ok(to_binary(&1u64).unwrap())) + }); + + // The hooks fire when a proposal is created. + let res = module + .execute( + deps.as_mut(), + mock_env(), + mock_info("a", &[]), + ExecuteMsg::Propose { + msg: Empty::default(), + }, + ) + .unwrap(); + assert_eq!( + res.messages[1], + SubMsg::new(WasmMsg::Execute { + contract_addr: "one".to_string(), + msg: to_binary(&Empty::default()).unwrap(), + funds: vec![], + }) + ); + + // Non-DAO addresses can not remove hooks. + let info = mock_info("n", &[]); + let err = module + .execute_remove_proposal_submitted_hook(deps.as_mut(), info, "one".to_string()) + .unwrap_err(); + assert_eq!(err, PreProposeError::NotDao {}); + + // The DAO can remove a hook. + let info = mock_info("d", &[]); + module + .execute_remove_proposal_submitted_hook(deps.as_mut(), info, "one".to_string()) + .unwrap(); + let hooks: HooksResponse = from_binary( + &module + .query( + deps.as_ref(), + mock_env(), + QueryMsg::ProposalSubmittedHooks {}, + ) + .unwrap(), + ) + .unwrap(); + assert!(hooks.hooks.is_empty()); +} + +#[test] +fn test_query_ext_does_nothing() { + let deps = mock_dependencies(); + let module = Contract::default(); + + let res = module + .query( + deps.as_ref(), + mock_env(), + QueryMsg::QueryExtension { + msg: Empty::default(), + }, + ) + .unwrap(); + assert_eq!(res, Binary::default()) +} + +#[test] +fn test_execute_ext_does_nothing() { + let mut deps = mock_dependencies(); + let module = Contract::default(); + + let res = module + .execute( + deps.as_mut(), + mock_env(), + mock_info("addr", &[]), + ExecuteMsg::Extension { + msg: Empty::default(), + }, + ) + .unwrap(); + assert_eq!(res, Response::default()) +} diff --git a/packages/dao-proposal-hooks/Cargo.toml b/packages/dao-proposal-hooks/Cargo.toml new file mode 100644 index 000000000..7ecb29919 --- /dev/null +++ b/packages/dao-proposal-hooks/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dao-proposal-hooks" +authors = ["Callum Anderson "] +description = "A package for managing proposal hooks." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-hooks = { workspace = true } +dao-voting = { workspace = true } diff --git a/packages/dao-proposal-hooks/README.md b/packages/dao-proposal-hooks/README.md new file mode 100644 index 000000000..2ab9ba5bb --- /dev/null +++ b/packages/dao-proposal-hooks/README.md @@ -0,0 +1,10 @@ +# CosmWasm DAO Proposal Hooks + +This package provides an interface for managing and dispatching +proposal hooks from a proposal module. + +There are two types of proposal hooks: +- **New Proposal Hook:** fired when a new proposal is created. +- **Proposal Staus Changed Hook:** fired when a proposal's status changes. + +Our wiki contains more info on [Proposal Hooks](https://github.com/DA0-DA0/dao-contracts/wiki/Proposal-Hooks-Interactions). diff --git a/packages/dao-proposal-hooks/src/lib.rs b/packages/dao-proposal-hooks/src/lib.rs new file mode 100644 index 000000000..9e0142a88 --- /dev/null +++ b/packages/dao-proposal-hooks/src/lib.rs @@ -0,0 +1,94 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, StdResult, Storage, SubMsg, WasmMsg}; +use cw_hooks::Hooks; +use dao_voting::reply::mask_proposal_hook_index; + +#[cw_serde] +pub enum ProposalHookMsg { + NewProposal { + id: u64, + proposer: String, + }, + ProposalStatusChanged { + id: u64, + old_status: String, + new_status: String, + }, +} + +// This is just a helper to properly serialize the above message +#[cw_serde] +pub enum ProposalHookExecuteMsg { + ProposalHook(ProposalHookMsg), +} + +/// Prepares new proposal hook messages. These messages reply on error +/// and have even reply IDs. +/// IDs are set to even numbers to then be interleaved with the vote hooks. +pub fn new_proposal_hooks( + hooks: Hooks, + storage: &dyn Storage, + id: u64, + proposer: &str, +) -> StdResult> { + let msg = to_binary(&ProposalHookExecuteMsg::ProposalHook( + ProposalHookMsg::NewProposal { + id, + proposer: proposer.to_string(), + }, + ))?; + + let mut index: u64 = 0; + let messages = hooks.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + let masked_index = mask_proposal_hook_index(index); + let tmp = SubMsg::reply_on_error(execute, masked_index); + index += 1; + Ok(tmp) + })?; + + Ok(messages) +} + +/// Prepares proposal status hook messages. These messages reply on error +/// and have even reply IDs. +/// IDs are set to even numbers to then be interleaved with the vote hooks. +pub fn proposal_status_changed_hooks( + hooks: Hooks, + storage: &dyn Storage, + id: u64, + old_status: String, + new_status: String, +) -> StdResult> { + if old_status == new_status { + return Ok(vec![]); + } + + let msg = to_binary(&ProposalHookExecuteMsg::ProposalHook( + ProposalHookMsg::ProposalStatusChanged { + id, + old_status, + new_status, + }, + ))?; + let mut index: u64 = 0; + let messages = hooks.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + let masked_index = mask_proposal_hook_index(index); + let tmp = SubMsg::reply_on_error(execute, masked_index); + index += 1; + Ok(tmp) + })?; + + Ok(messages) +} diff --git a/packages/dao-testing/Cargo.toml b/packages/dao-testing/Cargo.toml new file mode 100644 index 000000000..0ef5555cc --- /dev/null +++ b/packages/dao-testing/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "dao-testing" +authors = ["ekez ekez@withoutdoing.com"] +description = "Testing helper functions and interfaces for testing DAO modules." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +# This crate depends on multi-test and rand. These are not features in +# wasm builds of cosmwasm. Despite this crate only being used as a dev +# dependency, because it is part of the workspace it will always be +# compiled. There is no good way to remove a member from a workspace +# conditionally. As such, we don't compile anything here if we're +# targeting wasm. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-multi-test = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +cw4 = { workspace = true } +cw4-group = { workspace = true } +rand = { workspace = true } + +cw-core-v1 = { workspace = true, features = ["library"] } +cw-hooks = { workspace = true } +cw-proposal-single-v1 = { workspace = true } +cw-vesting = { workspace = true } +cw20-stake = { workspace = true } +cw721-base = { workspace = true } +cw721-roles = { workspace = true } +dao-dao-core = { workspace = true, features = ["library"] } +dao-interface = { workspace = true } +dao-pre-propose-multiple = { workspace = true } +dao-pre-propose-single = { workspace = true } +dao-proposal-condorcet = { workspace = true } +dao-proposal-single = { workspace = true } +dao-voting = { workspace = true } +dao-voting-cw20-balance = { workspace = true } +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-native-staked = { workspace = true } +voting-v1 = { workspace = true } +stake-cw20-v03 = { workspace = true } +token-bindings = { workpsace = true } diff --git a/packages/dao-testing/README.md b/packages/dao-testing/README.md new file mode 100644 index 000000000..7086baae1 --- /dev/null +++ b/packages/dao-testing/README.md @@ -0,0 +1,4 @@ +# CosmWasm DAO Testing + +This package provides common testing functions and types for testing +DAO modules. diff --git a/packages/dao-testing/src/contracts.rs b/packages/dao-testing/src/contracts.rs new file mode 100644 index 000000000..e82c2b9c1 --- /dev/null +++ b/packages/dao-testing/src/contracts.rs @@ -0,0 +1,198 @@ +use cosmwasm_std::Empty; + +use cw_multi_test::{Contract, ContractWrapper}; +use dao_pre_propose_multiple as cppm; +use dao_pre_propose_single as cpps; + +pub fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +pub fn cw4_group_contract() -> Box> { + let contract = ContractWrapper::new( + cw4_group::contract::execute, + cw4_group::contract::instantiate, + cw4_group::contract::query, + ); + Box::new(contract) +} + +pub fn cw721_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw721_base::entry::execute, + cw721_base::entry::instantiate, + cw721_base::entry::query, + ); + Box::new(contract) +} + +pub fn cw721_roles_contract() -> Box> { + let contract = ContractWrapper::new( + cw721_roles::contract::execute, + cw721_roles::contract::instantiate, + cw721_roles::contract::query, + ); + Box::new(contract) +} + +pub fn cw20_stake_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_stake::contract::execute, + cw20_stake::contract::instantiate, + cw20_stake::contract::query, + ); + Box::new(contract) +} + +pub fn proposal_condorcet_contract() -> Box> { + let contract = ContractWrapper::new( + dao_proposal_condorcet::contract::execute, + dao_proposal_condorcet::contract::instantiate, + dao_proposal_condorcet::contract::query, + ) + .with_reply(dao_proposal_condorcet::contract::reply); + Box::new(contract) +} + +pub fn proposal_single_contract() -> Box> { + let contract = ContractWrapper::new( + dao_proposal_single::contract::execute, + dao_proposal_single::contract::instantiate, + dao_proposal_single::contract::query, + ) + .with_reply(dao_proposal_single::contract::reply) + .with_migrate(dao_proposal_single::contract::migrate); + Box::new(contract) +} + +pub fn pre_propose_single_contract() -> Box> { + let contract = ContractWrapper::new( + cpps::contract::execute, + cpps::contract::instantiate, + cpps::contract::query, + ); + Box::new(contract) +} + +pub fn pre_propose_multiple_contract() -> Box> { + let contract = ContractWrapper::new( + cppm::contract::execute, + cppm::contract::instantiate, + cppm::contract::query, + ); + Box::new(contract) +} + +pub fn cw20_staked_balances_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw20_staked::contract::execute, + dao_voting_cw20_staked::contract::instantiate, + dao_voting_cw20_staked::contract::query, + ) + .with_reply(dao_voting_cw20_staked::contract::reply); + Box::new(contract) +} + +pub fn cw20_balances_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw20_balance::contract::execute, + dao_voting_cw20_balance::contract::instantiate, + dao_voting_cw20_balance::contract::query, + ) + .with_reply(dao_voting_cw20_balance::contract::reply); + Box::new(contract) +} + +pub fn native_staked_balances_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_native_staked::contract::execute, + dao_voting_native_staked::contract::instantiate, + dao_voting_native_staked::contract::query, + ); + Box::new(contract) +} + +pub fn voting_cw721_staked_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw721_staked::contract::execute, + dao_voting_cw721_staked::contract::instantiate, + dao_voting_cw721_staked::contract::query, + ) + .with_reply(dao_voting_cw721_staked::contract::reply); + Box::new(contract) +} + +pub fn dao_dao_contract() -> Box> { + let contract = ContractWrapper::new( + dao_dao_core::contract::execute, + dao_dao_core::contract::instantiate, + dao_dao_core::contract::query, + ) + .with_reply(dao_dao_core::contract::reply) + .with_migrate(dao_dao_core::contract::migrate); + Box::new(contract) +} + +pub fn dao_voting_cw4_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw4::contract::execute, + dao_voting_cw4::contract::instantiate, + dao_voting_cw4::contract::query, + ) + .with_reply(dao_voting_cw4::contract::reply); + Box::new(contract) +} + +pub fn dao_voting_cw721_roles_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw721_roles::contract::execute, + dao_voting_cw721_roles::contract::instantiate, + dao_voting_cw721_roles::contract::query, + ) + .with_reply(dao_voting_cw721_roles::contract::reply); + Box::new(contract) +} + +pub fn v1_proposal_single_contract() -> Box> { + let contract = ContractWrapper::new( + cw_proposal_single_v1::contract::execute, + cw_proposal_single_v1::contract::instantiate, + cw_proposal_single_v1::contract::query, + ) + .with_reply(cw_proposal_single_v1::contract::reply) + .with_migrate(cw_proposal_single_v1::contract::migrate); + Box::new(contract) +} + +pub fn v1_dao_dao_contract() -> Box> { + let contract = ContractWrapper::new( + cw_core_v1::contract::execute, + cw_core_v1::contract::instantiate, + cw_core_v1::contract::query, + ) + .with_reply(cw_core_v1::contract::reply); + Box::new(contract) +} + +pub fn cw_vesting_contract() -> Box> { + let contract = ContractWrapper::new( + cw_vesting::contract::execute, + cw_vesting::contract::instantiate, + cw_vesting::contract::query, + ); + Box::new(contract) +} + +pub fn stake_cw20_v03_contract() -> Box> { + let contract = ContractWrapper::new( + stake_cw20_v03::contract::execute, + stake_cw20_v03::contract::instantiate, + stake_cw20_v03::contract::query, + ); + Box::new(contract) +} diff --git a/packages/dao-testing/src/helpers.rs b/packages/dao-testing/src/helpers.rs new file mode 100644 index 000000000..ed95ea770 --- /dev/null +++ b/packages/dao-testing/src/helpers.rs @@ -0,0 +1,373 @@ +use cosmwasm_std::{to_binary, Addr, Binary, Uint128}; +use cw20::Cw20Coin; +use cw_multi_test::{App, Executor}; +use cw_utils::Duration; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_voting::threshold::ActiveThreshold; +use dao_voting_cw4::msg::GroupContract; + +use crate::contracts::{ + cw20_balances_voting_contract, cw20_base_contract, cw20_stake_contract, + cw20_staked_balances_voting_contract, cw4_group_contract, dao_dao_contract, + dao_voting_cw4_contract, +}; + +const CREATOR_ADDR: &str = "creator"; + +pub fn instantiate_with_cw20_balances_governance( + app: &mut App, + governance_code_id: u64, + governance_instantiate: Binary, + initial_balances: Option>, +) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let core_id = app.store_code(dao_dao_contract()); + let votemod_id = app.store_code(cw20_balances_voting_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + // Collapse balances so that we can test double votes. + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances, + marketing: None, + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: governance_code_id, + msg: governance_instantiate, + admin: Some(Admin::CoreModule {}), + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + app.instantiate_contract( + core_id, + Addr::unchecked(CREATOR_ADDR), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap() +} + +pub fn instantiate_with_staked_balances_governance( + app: &mut App, + governance_code_id: u64, + governance_instantiate: Binary, + initial_balances: Option>, +) -> Addr { + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + // Collapse balances so that we can test double votes. + let initial_balances: Vec = { + let mut already_seen = vec![]; + initial_balances + .into_iter() + .filter(|Cw20Coin { address, amount: _ }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .collect() + }; + + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_stake_id = app.store_code(cw20_stake_contract()); + let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); + let core_contract_id = app.store_code(dao_dao_contract()); + + let instantiate_core = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: staked_balances_voting_id, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + active_threshold: None, + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token.".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: initial_balances.clone(), + marketing: None, + staking_code_id: cw20_stake_id, + unstaking_duration: Some(Duration::Height(6)), + initial_dao_balance: None, + }, + }) + .unwrap(), + admin: None, + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: governance_code_id, + label: "DAO DAO governance module.".to_string(), + admin: Some(Admin::CoreModule {}), + msg: governance_instantiate, + }], + initial_items: None, + }; + + let core_addr = app + .instantiate_contract( + core_contract_id, + Addr::unchecked(CREATOR_ADDR), + &instantiate_core, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + let gov_state: dao_interface::query::DumpStateResponse = app + .wrap() + .query_wasm_smart( + core_addr.clone(), + &dao_interface::msg::QueryMsg::DumpState {}, + ) + .unwrap(); + let voting_module = gov_state.voting_module; + + let staking_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + let token_contract: Addr = app + .wrap() + .query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + ) + .unwrap(); + + // Stake all the initial balances. + for Cw20Coin { address, amount } in initial_balances { + app.execute_contract( + Addr::unchecked(address), + token_contract.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: staking_contract.to_string(), + amount, + msg: to_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + } + + // Update the block so that those staked balances appear. + app.update_block(|block| block.height += 1); + + core_addr +} + +pub fn instantiate_with_staking_active_threshold( + app: &mut App, + code_id: u64, + governance_instantiate: Binary, + initial_balances: Option>, + active_threshold: Option, +) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let cw20_staking_id = app.store_code(cw20_stake_contract()); + let governance_id = app.store_code(dao_dao_contract()); + let votemod_id = app.store_code(cw20_staked_balances_voting_contract()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![ + Cw20Coin { + address: "blob".to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: "blue".to_string(), + amount: Uint128::new(100_000_000), + }, + ] + }); + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances, + marketing: None, + staking_code_id: cw20_staking_id, + unstaking_duration: None, + initial_dao_balance: None, + }, + active_threshold, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id, + msg: governance_instantiate, + admin: Some(Admin::CoreModule {}), + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + app.instantiate_contract( + governance_id, + Addr::unchecked(CREATOR_ADDR), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap() +} + +pub fn instantiate_with_cw4_groups_governance( + app: &mut App, + core_code_id: u64, + proposal_module_instantiate: Binary, + initial_weights: Option>, +) -> Addr { + let cw4_id = app.store_code(cw4_group_contract()); + let core_id = app.store_code(dao_dao_contract()); + let votemod_id = app.store_code(dao_voting_cw4_contract()); + + let initial_weights = initial_weights.unwrap_or_default(); + + // Remove duplicates so that we can test duplicate voting. + let initial_weights: Vec = { + let mut already_seen = vec![]; + initial_weights + .into_iter() + .filter(|Cw20Coin { address, .. }| { + if already_seen.contains(address) { + false + } else { + already_seen.push(address.clone()); + true + } + }) + .map(|Cw20Coin { address, amount }| cw4::Member { + addr: address, + weight: amount.u128() as u64, + }) + .collect() + }; + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw4::msg::InstantiateMsg { + group_contract: GroupContract::New { + cw4_group_code_id: cw4_id, + initial_members: initial_weights, + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: core_code_id, + msg: proposal_module_instantiate, + admin: Some(Admin::CoreModule {}), + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + let addr = app + .instantiate_contract( + core_id, + Addr::unchecked(CREATOR_ADDR), + &governance_instantiate, + &[], + "DAO DAO", + None, + ) + .unwrap(); + + // Update the block so that weights appear. + app.update_block(|block| block.height += 1); + + addr +} diff --git a/packages/dao-testing/src/lib.rs b/packages/dao-testing/src/lib.rs new file mode 100644 index 000000000..56891f636 --- /dev/null +++ b/packages/dao-testing/src/lib.rs @@ -0,0 +1,13 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +#[cfg(not(target_arch = "wasm32"))] +pub mod tests; + +#[cfg(not(target_arch = "wasm32"))] +pub mod helpers; + +#[cfg(not(target_arch = "wasm32"))] +pub mod contracts; + +#[cfg(not(target_arch = "wasm32"))] +pub use tests::*; diff --git a/packages/dao-testing/src/tests.rs b/packages/dao-testing/src/tests.rs new file mode 100644 index 000000000..d57377333 --- /dev/null +++ b/packages/dao-testing/src/tests.rs @@ -0,0 +1,635 @@ +use cosmwasm_std::{Decimal, Uint128}; +use dao_voting::status::Status; +use dao_voting::threshold::{PercentageThreshold, Threshold}; +use dao_voting::voting::Vote; +use rand::{prelude::SliceRandom, Rng}; + +/// If a test vote should execute. Used for fuzzing and checking that +/// votes after a proposal has completed aren't allowed. +pub enum ShouldExecute { + /// This should execute. + Yes, + /// This should not execute. + No, + /// Doesn't matter. + Meh, +} + +pub struct TestSingleChoiceVote { + /// The address casting the vote. + pub voter: String, + /// Position on the vote. + pub position: Vote, + /// Voting power of the address. + pub weight: Uint128, + /// If this vote is expected to execute. + pub should_execute: ShouldExecute, +} + +pub fn test_simple_votes(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Passed, + None, + ); + + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Rejected, + None, + ) +} + +pub fn test_simple_vote_no_overflow(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(u128::max_value()), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Passed, + None, + ); +} + +pub fn test_vote_no_overflow(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(u128::max_value()), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Passed, + None, + ); + + do_votes( + vec![ + TestSingleChoiceVote { + voter: "zeke".to_string(), + position: Vote::No, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(u128::max_value() - 1), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(99)), + }, + Status::Passed, + None, + ) +} + +pub fn test_simple_early_rejection(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![TestSingleChoiceVote { + voter: "zeke".to_string(), + position: Vote::No, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Rejected, + None, + ); + + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(99)), + }, + Status::Open, + Some(Uint128::from(u128::max_value())), + ); +} + +pub fn test_vote_abstain_only(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Abstain, + weight: Uint128::new(u64::max_value().into()), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(100)), + }, + Status::Rejected, + None, + ); + + // The quorum shouldn't matter here in determining if the vote is + // rejected. + for i in 0..101 { + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Abstain, + weight: Uint128::new(u64::max_value().into()), + should_execute: ShouldExecute::Yes, + }], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(100)), + quorum: PercentageThreshold::Percent(Decimal::percent(i)), + }, + Status::Rejected, + None, + ); + } +} + +pub fn test_tricky_rounding(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + // This tests the smallest possible round up for passing + // thresholds we can have. Specifically, a 1% passing threshold + // and 1 total vote. This should round up and only pass if there + // are more than 1 yes votes. + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(1)), + }, + Status::Passed, + Some(Uint128::new(100)), + ); + + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(1)), + }, + Status::Passed, + Some(Uint128::new(1000)), + ); + + // HIGH PERCISION + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(9999999), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(1)), + }, + Status::Open, + Some(Uint128::new(1000000000)), + ); + + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Abstain, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(1)), + }, + Status::Rejected, + None, + ); +} + +pub fn test_no_double_votes(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Abstain, + weight: Uint128::new(2), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(2), + should_execute: ShouldExecute::No, + }, + ], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(100)), + }, + // NOTE: Updating our cw20-base version will cause this to + // fail. In versions of cw20-base before Feb 15 2022 (the one + // we use at the time of writing) it was allowed to have an + // initial balance that repeats for a given address but it + // would cause miscalculation of the total supply. In this + // case the total supply is miscumputed to be 4 so this is + // assumed to have 2 abstain votes out of 4 possible votes. + Status::Open, + Some(Uint128::new(10)), + ) +} + +pub fn test_votes_favor_yes(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Abstain, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::No, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "ezek".to_string(), + position: Vote::Yes, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(50)), + }, + Status::Passed, + None, + ); + + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Abstain, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::Yes, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(50)), + }, + Status::Passed, + None, + ); + + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Abstain, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::Yes, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + // Can vote up to expiration time. + TestSingleChoiceVote { + voter: "ezek".to_string(), + position: Vote::No, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Percent(Decimal::percent(50)), + }, + Status::Passed, + None, + ); +} + +pub fn test_votes_low_threshold(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::Yes, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(10)), + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + ); + + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::Yes, + weight: Uint128::new(5), + should_execute: ShouldExecute::Yes, + }, + // Can vote up to expiration time. + TestSingleChoiceVote { + voter: "ezek".to_string(), + position: Vote::No, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(10)), + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + ); +} + +pub fn test_majority_vs_half(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::Yes, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Percent(Decimal::percent(50)), + quorum: PercentageThreshold::Majority {}, + }, + Status::Passed, + None, + ); + + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + // Can vote up to expiration time, even if it already rejected. + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::Yes, + weight: Uint128::new(10), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Majority {}, + }, + Status::Rejected, + None, + ); +} + +pub fn test_pass_threshold_not_quorum(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(59), + should_execute: ShouldExecute::Yes, + }], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(60)), + }, + Status::Open, + Some(Uint128::new(100)), + ); + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(59), + should_execute: ShouldExecute::Yes, + }], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(60)), + }, + // As the threshold is 50% and 59% of voters have voted no + // this is unable to pass. + Status::Rejected, + Some(Uint128::new(100)), + ); +} + +pub fn test_pass_exactly_quorum(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(60), + should_execute: ShouldExecute::Yes, + }], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(60)), + }, + Status::Passed, + Some(Uint128::new(100)), + ); + do_votes( + vec![ + TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::Yes, + weight: Uint128::new(59), + should_execute: ShouldExecute::Yes, + }, + // This is an intersting one because in this case the no + // voter is actually incentivised not to vote. By voting + // they move the quorum over the threshold and pass the + // vote. In a DAO with sufficently involved stakeholders + // no voters should effectively never vote if there is a + // quorum higher than the threshold as it makes the + // passing threshold the quorum threshold. + TestSingleChoiceVote { + voter: "keze".to_string(), + position: Vote::No, + weight: Uint128::new(1), + should_execute: ShouldExecute::Yes, + }, + ], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(60)), + }, + Status::Passed, + Some(Uint128::new(100)), + ); + do_votes( + vec![TestSingleChoiceVote { + voter: "ekez".to_string(), + position: Vote::No, + weight: Uint128::new(60), + should_execute: ShouldExecute::Yes, + }], + Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(60)), + }, + Status::Rejected, + Some(Uint128::new(100)), + ); +} + +pub fn fuzz_voting(do_votes: F) +where + F: Fn(Vec, Threshold, Status, Option), +{ + let mut rng = rand::thread_rng(); + let dist = rand::distributions::Uniform::::new(1, 200); + for _ in 0..10 { + let yes: Vec = (0..50).map(|_| rng.sample(dist)).collect(); + let no: Vec = (0..50).map(|_| rng.sample(dist)).collect(); + + let yes_sum: u64 = yes.iter().sum(); + let no_sum: u64 = no.iter().sum(); + let expected_status = match yes_sum.cmp(&no_sum) { + std::cmp::Ordering::Less => Status::Rejected, + // Depends on which reaches the threshold first. Ignore for now. + std::cmp::Ordering::Equal => Status::Rejected, + std::cmp::Ordering::Greater => Status::Passed, + }; + + let yes = yes + .into_iter() + .enumerate() + .map(|(idx, weight)| TestSingleChoiceVote { + voter: format!("yes_{idx}"), + position: Vote::Yes, + weight: Uint128::new(weight as u128), + should_execute: ShouldExecute::Meh, + }); + let no = no + .into_iter() + .enumerate() + .map(|(idx, weight)| TestSingleChoiceVote { + voter: format!("no_{idx}"), + position: Vote::No, + weight: Uint128::new(weight as u128), + should_execute: ShouldExecute::Meh, + }); + let mut votes = yes.chain(no).collect::>(); + votes.shuffle(&mut rng); + + do_votes( + votes, + Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + expected_status, + None, + ); + } +} diff --git a/packages/dao-vote-hooks/Cargo.toml b/packages/dao-vote-hooks/Cargo.toml new file mode 100644 index 000000000..067e11158 --- /dev/null +++ b/packages/dao-vote-hooks/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dao-vote-hooks" +authors = ["ekez ekez@withoutdoing.com"] +description = "A package for managing vote hooks." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-hooks = { workspace = true } +dao-voting = { workspace = true } diff --git a/packages/dao-vote-hooks/README.md b/packages/dao-vote-hooks/README.md new file mode 100644 index 000000000..ae2be9cca --- /dev/null +++ b/packages/dao-vote-hooks/README.md @@ -0,0 +1,7 @@ +# CosmWasm DAO Vote Hooks + +This package provides an interface for managing and dispatching +vote hooks from a proposal module. Vote hooks are fired when new +votes are cast. + +You can read more about vote hooks in our [wiki](https://github.com/DA0-DA0/dao-contracts/wiki/Proposal-Hooks-Interactions). diff --git a/packages/dao-vote-hooks/src/lib.rs b/packages/dao-vote-hooks/src/lib.rs new file mode 100644 index 000000000..cfc13aedd --- /dev/null +++ b/packages/dao-vote-hooks/src/lib.rs @@ -0,0 +1,50 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{to_binary, StdResult, Storage, SubMsg, WasmMsg}; +use cw_hooks::Hooks; +use dao_voting::reply::mask_vote_hook_index; + +#[cw_serde] +pub enum VoteHookMsg { + NewVote { + proposal_id: u64, + voter: String, + vote: String, + }, +} + +// This is just a helper to properly serialize the above message +#[cw_serde] +pub enum VoteHookExecuteMsg { + VoteHook(VoteHookMsg), +} + +/// Prepares new vote hook messages. These messages reply on error +/// and have even reply IDs. +/// IDs are set to odd numbers to then be interleaved with the proposal hooks. +pub fn new_vote_hooks( + hooks: Hooks, + storage: &dyn Storage, + proposal_id: u64, + voter: String, + vote: String, +) -> StdResult> { + let msg = to_binary(&VoteHookExecuteMsg::VoteHook(VoteHookMsg::NewVote { + proposal_id, + voter, + vote, + }))?; + let mut index: u64 = 0; + hooks.prepare_hooks(storage, |a| { + let execute = WasmMsg::Execute { + contract_addr: a.to_string(), + msg: msg.clone(), + funds: vec![], + }; + let masked_index = mask_vote_hook_index(index); + let tmp = SubMsg::reply_on_error(execute, masked_index); + index += 1; + Ok(tmp) + }) +} diff --git a/packages/dao-voting/Cargo.toml b/packages/dao-voting/Cargo.toml new file mode 100644 index 000000000..ced2f11db --- /dev/null +++ b/packages/dao-voting/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "dao-voting" +authors = ["ekez ekez@withoutdoing.com"] +description = "Types and methods for CosmWasm DAO voting." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +thiserror = { workspace = true } +cw20 = { workspace = true } +dao-interface = { workspace = true } +dao-dao-macros = { workspace = true } +cw-denom = { workspace = true } +cw-utils = { workspace = true } +cw-storage-plus = { workspace = true } diff --git a/packages/dao-voting/README.md b/packages/dao-voting/README.md new file mode 100644 index 000000000..478c5e241 --- /dev/null +++ b/packages/dao-voting/README.md @@ -0,0 +1,4 @@ +# CosmWasm DAO Voting + +This package provides types and associated methods for handling voting +in a CosmWasm DAO. diff --git a/packages/dao-voting/src/deposit.rs b/packages/dao-voting/src/deposit.rs new file mode 100644 index 000000000..bf0852ea8 --- /dev/null +++ b/packages/dao-voting/src/deposit.rs @@ -0,0 +1,390 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ + to_binary, Addr, CosmosMsg, Deps, MessageInfo, StdError, StdResult, Uint128, WasmMsg, +}; +use cw_utils::{must_pay, PaymentError}; + +use thiserror::Error; + +use cw_denom::{CheckedDenom, DenomError, UncheckedDenom}; + +/// Error type for deposit methods. +#[derive(Error, Debug, PartialEq)] +pub enum DepositError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Payment(#[from] PaymentError), + + #[error(transparent)] + Denom(#[from] DenomError), + + #[error("invalid zero deposit. set the deposit to `None` to have no deposit")] + ZeroDeposit, + + #[error("invalid deposit amount. got ({actual}), expected ({expected})")] + InvalidDeposit { actual: Uint128, expected: Uint128 }, +} + +/// Information about the token to use for proposal deposits. +#[cw_serde] +pub enum DepositToken { + /// Use a specific token address as the deposit token. + Token { denom: UncheckedDenom }, + /// Use the token address of the associated DAO's voting + /// module. NOTE: in order to use the token address of the voting + /// module the voting module must (1) use a cw20 token and (2) + /// implement the `TokenContract {}` query type defined by + /// `dao_dao_macros::token_query`. Failing to implement that + /// and using this option will cause instantiation to fail. + VotingModuleToken {}, +} + +/// Information about the deposit required to create a proposal. +#[cw_serde] +pub struct UncheckedDepositInfo { + /// The address of the token to be used for proposal deposits. + pub denom: DepositToken, + /// The number of tokens that must be deposited to create a + /// proposal. Must be a positive, non-zero number. + pub amount: Uint128, + /// The policy used for refunding deposits on proposal completion. + pub refund_policy: DepositRefundPolicy, +} + +#[cw_serde] +pub enum DepositRefundPolicy { + /// Deposits should always be refunded. + Always, + /// Deposits should only be refunded for passed proposals. + OnlyPassed, + /// Deposits should never be refunded. + Never, +} + +/// Counterpart to the `DepositInfo` struct which has been +/// processed. This type should never be constructed literally and +/// should always by built by calling `into_checked` on a +/// `DepositInfo` instance. +#[cw_serde] +pub struct CheckedDepositInfo { + /// The address of the cw20 token to be used for proposal + /// deposits. + pub denom: CheckedDenom, + /// The number of tokens that must be deposited to create a + /// proposal. This is validated to be non-zero if this struct is + /// constructed by converted via the `into_checked` method on + /// `DepositInfo`. + pub amount: Uint128, + /// The policy used for refunding proposal deposits. + pub refund_policy: DepositRefundPolicy, +} + +impl UncheckedDepositInfo { + /// Converts deposit info into checked deposit info. + pub fn into_checked(self, deps: Deps, dao: Addr) -> Result { + let Self { + denom, + amount, + refund_policy, + } = self; + // Check that the deposit is non-zero. Modules should make + // deposit information optional and consumers should provide + // `None` when they do not want to have a proposal deposit. + if amount.is_zero() { + return Err(DepositError::ZeroDeposit); + } + + let denom = match denom { + DepositToken::Token { denom } => denom.into_checked(deps), + DepositToken::VotingModuleToken {} => { + let voting_module: Addr = deps + .querier + .query_wasm_smart(dao, &dao_interface::msg::QueryMsg::VotingModule {})?; + // If the voting module has no token this will + // error. This is desirable. + let token_addr: Addr = deps.querier.query_wasm_smart( + voting_module, + &dao_interface::voting::Query::TokenContract {}, + )?; + // We don't assume here that the voting module has + // returned a valid token. Conversion of the unchecked + // denom into a checked one will do a `TokenInfo {}` + // query. + UncheckedDenom::Cw20(token_addr.into_string()).into_checked(deps) + } + }?; + + Ok(CheckedDepositInfo { + denom, + amount, + refund_policy, + }) + } +} + +impl CheckedDepositInfo { + pub fn check_native_deposit_paid(&self, info: &MessageInfo) -> Result<(), DepositError> { + if let Self { + amount, + denom: CheckedDenom::Native(denom), + .. + } = self + { + // must_pay > may_pay. The method this is getting called + // in is accepting a deposit. It seems likely to me that + // if other payments are here it's a bug in a frontend and + // not an intentional thing. + let paid = must_pay(info, denom)?; + if paid != *amount { + Err(DepositError::InvalidDeposit { + actual: paid, + expected: *amount, + }) + } else { + Ok(()) + } + } else { + // Nothing to do if we're a cw20. + Ok(()) + } + } + + pub fn get_take_deposit_messages( + &self, + depositor: &Addr, + contract: &Addr, + ) -> StdResult> { + let take_deposit_msg: Vec = if let Self { + amount, + denom: CheckedDenom::Cw20(address), + .. + } = self + { + // into_checked() makes sure this isn't the case, but just for + // posterity. + if amount.is_zero() { + vec![] + } else { + vec![WasmMsg::Execute { + contract_addr: address.to_string(), + funds: vec![], + msg: to_binary(&cw20::Cw20ExecuteMsg::TransferFrom { + owner: depositor.to_string(), + recipient: contract.to_string(), + amount: *amount, + })?, + } + .into()] + } + } else { + // Deposits are pushed, not pulled for native + // deposits. See: `check_native_deposit_paid`. + vec![] + }; + Ok(take_deposit_msg) + } + + pub fn get_return_deposit_message(&self, depositor: &Addr) -> StdResult> { + // Should get caught in `into_checked()`, but to be pedantic. + if self.amount.is_zero() { + return Ok(vec![]); + } + let message = self.denom.get_transfer_to_message(depositor, self.amount)?; + Ok(vec![message]) + } +} + +#[cfg(test)] +pub mod tests { + use cosmwasm_std::{coin, coins, testing::mock_info, BankMsg}; + + use super::*; + + const NATIVE_DENOM: &str = "uekez"; + const CW20: &str = "cw20"; + + #[test] + fn test_check_native_deposit_paid_yes() { + let info = mock_info("ekez", &coins(10, NATIVE_DENOM)); + let deposit_info = CheckedDepositInfo { + denom: CheckedDenom::Native(NATIVE_DENOM.to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }; + deposit_info.check_native_deposit_paid(&info).unwrap(); + + let mut info = info; + let mut deposit_info = deposit_info; + + // Doesn't matter what we submit if it's a cw20 token. + info.funds = vec![]; + deposit_info.denom = CheckedDenom::Cw20(Addr::unchecked(CW20)); + deposit_info.check_native_deposit_paid(&info).unwrap(); + + info.funds = coins(100, NATIVE_DENOM); + deposit_info.check_native_deposit_paid(&info).unwrap(); + } + + #[test] + fn test_native_deposit_paid_wrong_amount() { + let info = mock_info("ekez", &coins(9, NATIVE_DENOM)); + let deposit_info = CheckedDepositInfo { + denom: CheckedDenom::Native(NATIVE_DENOM.to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }; + let err = deposit_info.check_native_deposit_paid(&info).unwrap_err(); + assert_eq!( + err, + DepositError::InvalidDeposit { + actual: Uint128::new(9), + expected: Uint128::new(10) + } + ) + } + + #[test] + fn check_native_deposit_paid_wrong_denom() { + let info = mock_info("ekez", &coins(10, "unotekez")); + let deposit_info = CheckedDepositInfo { + denom: CheckedDenom::Native(NATIVE_DENOM.to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }; + let err = deposit_info.check_native_deposit_paid(&info).unwrap_err(); + assert_eq!( + err, + DepositError::Payment(PaymentError::MissingDenom(NATIVE_DENOM.to_string())) + ); + } + + // If you're receiving other denoms in the same place you're + // receiving your deposit you should probably write your own + // package, or you're working on dao dao and can remove this + // one. At the time of writing, other denoms coming in with a + // deposit seems like a frontend bug off. + #[test] + fn check_sending_other_denoms_is_not_allowed() { + let info = mock_info("ekez", &[coin(10, "unotekez"), coin(10, "ekez")]); + let deposit_info = CheckedDepositInfo { + denom: CheckedDenom::Native(NATIVE_DENOM.to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }; + + let err = deposit_info.check_native_deposit_paid(&info).unwrap_err(); + assert_eq!(err, DepositError::Payment(PaymentError::MultipleDenoms {})); + } + + #[test] + fn check_native_deposit_paid_no_denoms() { + let info = mock_info("ekez", &[]); + let deposit_info = CheckedDepositInfo { + denom: CheckedDenom::Native(NATIVE_DENOM.to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }; + let err = deposit_info.check_native_deposit_paid(&info).unwrap_err(); + assert_eq!(err, DepositError::Payment(PaymentError::NoFunds {})); + } + + #[test] + fn test_get_take_deposit_messages() { + // Does nothing if a native token is being used. + let mut deposit_info = CheckedDepositInfo { + denom: CheckedDenom::Native(NATIVE_DENOM.to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }; + let messages = deposit_info + .get_take_deposit_messages(&Addr::unchecked("ekez"), &Addr::unchecked(CW20)) + .unwrap(); + assert_eq!(messages, vec![]); + + // Does something for cw20s. + deposit_info.denom = CheckedDenom::Cw20(Addr::unchecked(CW20)); + let messages = deposit_info + .get_take_deposit_messages(&Addr::unchecked("ekez"), &Addr::unchecked("contract")) + .unwrap(); + assert_eq!( + messages, + vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: CW20.to_string(), + msg: to_binary(&cw20::Cw20ExecuteMsg::TransferFrom { + owner: "ekez".to_string(), + recipient: "contract".to_string(), + amount: Uint128::new(10) + }) + .unwrap(), + funds: vec![], + })] + ); + + // Does nothing when the amount is zero (this would cause the + // tx to fail for a valid cw20). + deposit_info.amount = Uint128::zero(); + let messages = deposit_info + .get_take_deposit_messages(&Addr::unchecked("ekez"), &Addr::unchecked(CW20)) + .unwrap(); + assert_eq!(messages, vec![]); + } + + #[test] + fn test_get_return_deposit_message_native() { + let mut deposit_info = CheckedDepositInfo { + denom: CheckedDenom::Native(NATIVE_DENOM.to_string()), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }; + let messages = deposit_info + .get_return_deposit_message(&Addr::unchecked("ekez")) + .unwrap(); + assert_eq!( + messages, + vec![CosmosMsg::Bank(BankMsg::Send { + to_address: "ekez".to_string(), + amount: coins(10, "uekez") + })] + ); + + // Don't fire a message if there is nothing to send! + deposit_info.amount = Uint128::zero(); + let messages = deposit_info + .get_return_deposit_message(&Addr::unchecked("ekez")) + .unwrap(); + assert_eq!(messages, vec![]); + } + + #[test] + fn test_get_return_deposit_message_cw20() { + let mut deposit_info = CheckedDepositInfo { + denom: CheckedDenom::Cw20(Addr::unchecked(CW20)), + amount: Uint128::new(10), + refund_policy: DepositRefundPolicy::Always, + }; + let messages = deposit_info + .get_return_deposit_message(&Addr::unchecked("ekez")) + .unwrap(); + assert_eq!( + messages, + vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: CW20.to_string(), + msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: "ekez".to_string(), + amount: Uint128::new(10) + }) + .unwrap(), + funds: vec![] + })] + ); + + // Don't fire a message if there is nothing to send! + deposit_info.amount = Uint128::zero(); + let messages = deposit_info + .get_return_deposit_message(&Addr::unchecked("ekez")) + .unwrap(); + assert_eq!(messages, vec![]); + } +} diff --git a/packages/dao-voting/src/error.rs b/packages/dao-voting/src/error.rs new file mode 100644 index 000000000..18843be12 --- /dev/null +++ b/packages/dao-voting/src/error.rs @@ -0,0 +1,14 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum VotingError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("min_voting_period and max_voting_period must have the same units (height or time)")] + DurationUnitsConflict {}, + + #[error("Min voting period must be less than or equal to max voting period")] + InvalidMinVotingPeriod {}, +} diff --git a/packages/dao-voting/src/lib.rs b/packages/dao-voting/src/lib.rs new file mode 100644 index 000000000..208cfc468 --- /dev/null +++ b/packages/dao-voting/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod deposit; +pub mod error; +pub mod multiple_choice; +pub mod pre_propose; +pub mod proposal; +pub mod reply; +pub mod status; +pub mod threshold; +pub mod voting; diff --git a/packages/dao-voting/src/multiple_choice.rs b/packages/dao-voting/src/multiple_choice.rs new file mode 100644 index 000000000..91531a09f --- /dev/null +++ b/packages/dao-voting/src/multiple_choice.rs @@ -0,0 +1,266 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{CosmosMsg, Empty, StdError, StdResult, Uint128}; + +use crate::threshold::{validate_quorum, PercentageThreshold, ThresholdError}; + +/// Maximum number of choices for multiple choice votes. Chosen +/// in order to impose a bound on state / queries. +pub const MAX_NUM_CHOICES: u32 = 20; +const NONE_OPTION_DESCRIPTION: &str = "None of the above"; + +/// Determines how many choices may be selected. +#[cw_serde] +pub enum VotingStrategy { + SingleChoice { quorum: PercentageThreshold }, +} + +impl VotingStrategy { + pub fn validate(&self) -> Result<(), ThresholdError> { + match self { + VotingStrategy::SingleChoice { quorum } => validate_quorum(quorum), + } + } + + pub fn get_quorum(&self) -> PercentageThreshold { + match self { + VotingStrategy::SingleChoice { quorum } => *quorum, + } + } +} + +/// A multiple choice vote, picking the desired option +#[cw_serde] +#[derive(Copy)] +pub struct MultipleChoiceVote { + // A vote indicates which option the user has selected. + pub option_id: u32, +} + +impl std::fmt::Display for MultipleChoiceVote { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.option_id) + } +} + +// Holds the vote weights for each option +#[cw_serde] +pub struct MultipleChoiceVotes { + // Vote counts is a vector of integers indicating the vote weight for each option + // (the index corresponds to the option). + pub vote_weights: Vec, +} + +impl MultipleChoiceVotes { + /// Sum of all vote weights + pub fn total(&self) -> Uint128 { + self.vote_weights.iter().sum() + } + + // Add a vote to the tally + pub fn add_vote(&mut self, vote: MultipleChoiceVote, weight: Uint128) -> StdResult<()> { + self.vote_weights[vote.option_id as usize] = self.vote_weights[vote.option_id as usize] + .checked_add(weight) + .map_err(StdError::overflow)?; + Ok(()) + } + + // Remove a vote from the tally + pub fn remove_vote(&mut self, vote: MultipleChoiceVote, weight: Uint128) -> StdResult<()> { + self.vote_weights[vote.option_id as usize] = self.vote_weights[vote.option_id as usize] + .checked_sub(weight) + .map_err(StdError::overflow)?; + Ok(()) + } + + // Default tally of zero for all multiple choice options + pub fn zero(num_choices: usize) -> Self { + Self { + vote_weights: vec![Uint128::zero(); num_choices], + } + } +} + +/// Represents the type of Multiple choice option. "None of the above" has a special +/// type for example. +#[cw_serde] +pub enum MultipleChoiceOptionType { + /// Choice that represents selecting none of the options; still counts toward quorum + /// and allows proposals with all bad options to be voted against. + None, + Standard, +} + +/// Represents unchecked multiple choice options +#[cw_serde] +pub struct MultipleChoiceOptions { + pub options: Vec, +} + +/// Unchecked multiple choice option +#[cw_serde] +pub struct MultipleChoiceOption { + pub title: String, + pub description: String, + pub msgs: Vec>, +} + +/// Multiple choice options that have been verified for correctness, and have all fields +/// necessary for voting. +#[cw_serde] +pub struct CheckedMultipleChoiceOptions { + pub options: Vec, +} + +/// A verified option that has all fields needed for voting. +#[cw_serde] +pub struct CheckedMultipleChoiceOption { + // This is the index of the option in both the vote_weights and proposal.choices vectors. + // Workaround due to not being able to use HashMaps in Cosmwasm. + pub index: u32, + pub option_type: MultipleChoiceOptionType, + pub title: String, + pub description: String, + pub msgs: Vec>, + pub vote_count: Uint128, +} + +impl MultipleChoiceOptions { + pub fn into_checked(self) -> StdResult { + if self.options.len() < 2 || self.options.len() > MAX_NUM_CHOICES as usize { + return Err(StdError::GenericErr { + msg: "Wrong number of choices".to_string(), + }); + } + + let mut checked_options: Vec = + Vec::with_capacity(self.options.len() + 1); + + // Iterate through choices and save the index and option type for each + self.options + .into_iter() + .enumerate() + .for_each(|(idx, choice)| { + let checked_option = CheckedMultipleChoiceOption { + index: idx as u32, + option_type: MultipleChoiceOptionType::Standard, + description: choice.description, + msgs: choice.msgs, + vote_count: Uint128::zero(), + title: choice.title, + }; + checked_options.push(checked_option) + }); + + // Add a "None of the above" option, required for every multiple choice proposal. + let none_option = CheckedMultipleChoiceOption { + index: (checked_options.capacity() - 1) as u32, + option_type: MultipleChoiceOptionType::None, + description: NONE_OPTION_DESCRIPTION.to_string(), + msgs: vec![], + vote_count: Uint128::zero(), + title: NONE_OPTION_DESCRIPTION.to_string(), + }; + + checked_options.push(none_option); + + let options = CheckedMultipleChoiceOptions { + options: checked_options, + }; + Ok(options) + } +} + +#[cfg(test)] +mod test { + use std::vec; + + use super::*; + + #[test] + fn test_display_multiple_choice_vote() { + let vote = MultipleChoiceVote { option_id: 0 }; + assert_eq!("0", vote.to_string()) + } + + #[test] + fn test_multiple_choice_votes() { + let mut votes = MultipleChoiceVotes { + vote_weights: vec![Uint128::new(10), Uint128::new(100)], + }; + let total = votes.total(); + assert_eq!(total, Uint128::new(110)); + + votes + .add_vote(MultipleChoiceVote { option_id: 0 }, Uint128::new(10)) + .unwrap(); + let total = votes.total(); + assert_eq!(total, Uint128::new(120)); + + votes + .remove_vote(MultipleChoiceVote { option_id: 0 }, Uint128::new(20)) + .unwrap(); + votes + .remove_vote(MultipleChoiceVote { option_id: 1 }, Uint128::new(100)) + .unwrap(); + + assert_eq!(votes, MultipleChoiceVotes::zero(2)) + } + + #[test] + fn test_into_checked() { + let options = vec![ + super::MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + super::MultipleChoiceOption { + description: "multiple choice option 2".to_string(), + msgs: vec![], + title: "title".to_string(), + }, + ]; + + let mc_options = super::MultipleChoiceOptions { options }; + + let checked_mc_options = mc_options.into_checked().unwrap(); + assert_eq!(checked_mc_options.options.len(), 3); + assert_eq!( + checked_mc_options.options[0].option_type, + super::MultipleChoiceOptionType::Standard + ); + assert_eq!( + checked_mc_options.options[0].description, + "multiple choice option 1", + ); + assert_eq!( + checked_mc_options.options[1].option_type, + super::MultipleChoiceOptionType::Standard + ); + assert_eq!( + checked_mc_options.options[1].description, + "multiple choice option 2", + ); + assert_eq!( + checked_mc_options.options[2].option_type, + super::MultipleChoiceOptionType::None + ); + assert_eq!( + checked_mc_options.options[2].description, + super::NONE_OPTION_DESCRIPTION, + ); + } + + #[should_panic(expected = "Wrong number of choices")] + #[test] + fn test_into_checked_wrong_num_choices() { + let options = vec![super::MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }]; + + let mc_options = super::MultipleChoiceOptions { options }; + mc_options.into_checked().unwrap(); + } +} diff --git a/packages/dao-voting/src/pre_propose.rs b/packages/dao-voting/src/pre_propose.rs new file mode 100644 index 000000000..52bd3e7a5 --- /dev/null +++ b/packages/dao-voting/src/pre_propose.rs @@ -0,0 +1,146 @@ +//! Types related to the pre-propose module. Motivation: +//! . + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Empty, StdResult, SubMsg}; +use dao_interface::state::ModuleInstantiateInfo; + +use crate::reply::pre_propose_module_instantiation_id; + +#[cw_serde] +pub enum PreProposeInfo { + /// Anyone may create a proposal free of charge. + AnyoneMayPropose {}, + /// The module specified in INFO has exclusive rights to proposal + /// creation. + ModuleMayPropose { info: ModuleInstantiateInfo }, +} + +#[cw_serde] +pub enum ProposalCreationPolicy { + /// Anyone may create a proposal, free of charge. + Anyone {}, + /// Only ADDR may create proposals. It is expected that ADDR is a + /// pre-propose module, though we only require that it is a valid + /// address. + Module { addr: Addr }, +} + +impl ProposalCreationPolicy { + /// Determines if CREATOR is permitted to create a + /// proposal. Returns true if so and false otherwise. + pub fn is_permitted(&self, creator: &Addr) -> bool { + match self { + Self::Anyone {} => true, + Self::Module { addr } => creator == addr, + } + } +} + +impl PreProposeInfo { + pub fn into_initial_policy_and_messages( + self, + dao: Addr, + ) -> StdResult<(ProposalCreationPolicy, Vec>)> { + Ok(match self { + Self::AnyoneMayPropose {} => (ProposalCreationPolicy::Anyone {}, vec![]), + Self::ModuleMayPropose { info } => ( + // Anyone can propose will be set until instantiation succeeds, then + // `ModuleMayPropose` will be set. This ensures that we fail open + // upon instantiation failure. + ProposalCreationPolicy::Anyone {}, + vec![SubMsg::reply_on_success( + info.into_wasm_msg(dao), + pre_propose_module_instantiation_id(), + )], + ), + }) + } +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::{to_binary, WasmMsg}; + + use super::*; + + #[test] + fn test_anyone_is_permitted() { + let policy = ProposalCreationPolicy::Anyone {}; + + // I'll actually stand by this as a legit testing strategy + // when looking at string inputs. If anything is going to + // screw things up, its weird unicode characters. + // + // For example, my langauge server explodes for me if I use + // the granddaddy of weird unicode characters, the large + // family: 👩‍👩‍👧‍👦. + // + // The family emoji you see is actually a combination of + // individual person emojis. You can browse the whole + // collection of combo emojis here: + // . + // + // You may also enjoy this PDF wherein there is a discussion + // about the feesability of supporting all 7230 possible + // combos of family emojis: + // . + for c in '😀'..'🤣' { + assert!(policy.is_permitted(&Addr::unchecked(c.to_string()))) + } + } + + #[test] + fn test_module_is_permitted() { + let policy = ProposalCreationPolicy::Module { + addr: Addr::unchecked("deposit_module"), + }; + assert!(!policy.is_permitted(&Addr::unchecked("👩‍👩‍👧‍👦"))); + assert!(policy.is_permitted(&Addr::unchecked("deposit_module"))); + } + + #[test] + fn test_pre_any_conversion() { + let info = PreProposeInfo::AnyoneMayPropose {}; + let (policy, messages) = info + .into_initial_policy_and_messages(Addr::unchecked("😃")) + .unwrap(); + assert_eq!(policy, ProposalCreationPolicy::Anyone {}); + assert!(messages.is_empty()) + } + + #[test] + fn test_pre_module_conversion() { + let info = PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: 42, + msg: to_binary("foo").unwrap(), + admin: None, + label: "pre-propose-9000".to_string(), + }, + }; + let (policy, messages) = info + .into_initial_policy_and_messages(Addr::unchecked("🥵")) + .unwrap(); + + // In this case the package is expected to allow anyone to + // create a proposal (fail-open), and provide some messages + // that, when handled in a `reply` handler will set the + // creation policy to a specific module. + assert_eq!(policy, ProposalCreationPolicy::Anyone {}); + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0], + SubMsg::reply_on_success( + WasmMsg::Instantiate { + admin: None, + code_id: 42, + msg: to_binary("foo").unwrap(), + funds: vec![], + label: "pre-propose-9000".to_string() + }, + crate::reply::pre_propose_module_instantiation_id() + ) + ) + } +} diff --git a/packages/dao-voting/src/proposal.rs b/packages/dao-voting/src/proposal.rs new file mode 100644 index 000000000..ba685de0d --- /dev/null +++ b/packages/dao-voting/src/proposal.rs @@ -0,0 +1,34 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{CosmosMsg, Empty}; + +/// Default limit for proposal pagination. +pub const DEFAULT_LIMIT: u64 = 30; +pub const MAX_PROPOSAL_SIZE: u64 = 30_000; + +/// The contents of a message to create a proposal in the single +/// choice proposal module. +/// +/// We break this type out of `ExecuteMsg` because we want pre-propose +/// modules that interact with this contract to be able to get type +/// checking on their propose messages. +/// +/// We move this type to this package so that pre-propose modules can +/// import it without importing dao-proposal-single with the library +/// feature which (as it is not additive) cause the execute exports to +/// not be included in wasm builds. +#[cw_serde] +pub struct SingleChoiceProposeMsg { + /// The title of the proposal. + pub title: String, + /// A description of the proposal. + pub description: String, + /// The messages that should be executed in response to this + /// proposal passing. + pub msgs: Vec>, + /// The address creating the proposal. If no pre-propose + /// module is attached to this module this must always be None + /// as the proposer is the sender of the propose message. If a + /// pre-propose module is attached, this must be Some and will + /// set the proposer of the proposal it creates. + pub proposer: Option, +} diff --git a/packages/dao-voting/src/reply.rs b/packages/dao-voting/src/reply.rs new file mode 100644 index 000000000..dbf916e2b --- /dev/null +++ b/packages/dao-voting/src/reply.rs @@ -0,0 +1,121 @@ +use dao_dao_macros::limit_variant_count; + +const FAILED_PROPOSAL_EXECUTION_MASK: u64 = 0b000; +const FAILED_PROPOSAL_HOOK_MASK: u64 = 0b001; +const FAILED_VOTE_HOOK_MASK: u64 = 0b010; + +/// These are IDs as opposed to bitmasks since they only need to +/// convey one piece of information (the type of reply the reply +/// handler is handling.) +const PRE_PROPOSE_MODULE_INSTANTIATION_ID: u64 = 0b011; +const FAILED_PRE_PROPOSE_MODULE_HOOK_ID: u64 = 0b100; + +const BITS_RESERVED_FOR_REPLY_TYPE: u8 = 3; +const REPLY_TYPE_MASK: u64 = (1 << BITS_RESERVED_FOR_REPLY_TYPE) - 1; + +/// Since we can only pass `id`, and we need to perform different actions in reply, +/// we decided to take few bits to identify "Reply Type". +/// See +// Limit variant count to `2 ** BITS_RESERVED_FOR_REPLY_TYPE`. This +// must be manually updated if additional bits are allocated as +// constexpr and procedural macros are seprate in the rust compiler. +#[limit_variant_count(8)] +#[cfg_attr(not(target_arch = "wasm32"), derive(Debug, PartialEq, Eq))] +pub enum TaggedReplyId { + /// Fired when a proposal's execution fails. + FailedProposalExecution(u64), + /// Fired when a proposal hook's execution fails. + FailedProposalHook(u64), + /// Fired when a vote hook's execution fails. + FailedVoteHook(u64), + /// Fired when a pre-propse module's execution fails. + FailedPreProposeModuleHook, + /// Fired when a pre-propose module is successfully instantiated. + PreProposeModuleInstantiation, +} + +impl TaggedReplyId { + /// Takes `Reply.id` and returns tagged version of it, + /// depending on a first few bits. + /// + /// We know it costs extra to pattern match, but cleaner code in `reply` Methods + pub fn new(id: u64) -> Result { + let reply_type = id & REPLY_TYPE_MASK; + let id_after_shift = id >> BITS_RESERVED_FOR_REPLY_TYPE; + match reply_type { + FAILED_PROPOSAL_EXECUTION_MASK => { + Ok(TaggedReplyId::FailedProposalExecution(id_after_shift)) + } + FAILED_PROPOSAL_HOOK_MASK => Ok(TaggedReplyId::FailedProposalHook(id_after_shift)), + FAILED_VOTE_HOOK_MASK => Ok(TaggedReplyId::FailedVoteHook(id_after_shift)), + PRE_PROPOSE_MODULE_INSTANTIATION_ID => Ok(TaggedReplyId::PreProposeModuleInstantiation), + FAILED_PRE_PROPOSE_MODULE_HOOK_ID => Ok(TaggedReplyId::FailedPreProposeModuleHook), + _ => Err(error::TagError::UnknownReplyId { id }), + } + } +} + +/// This function can drop bits, if you have more than `u(64-[`BITS_RESERVED_FOR_REPLY_TYPE`])` proposals. +pub const fn mask_proposal_execution_proposal_id(proposal_id: u64) -> u64 { + FAILED_PROPOSAL_EXECUTION_MASK | (proposal_id << BITS_RESERVED_FOR_REPLY_TYPE) +} + +pub const fn mask_proposal_hook_index(index: u64) -> u64 { + FAILED_PROPOSAL_HOOK_MASK | (index << BITS_RESERVED_FOR_REPLY_TYPE) +} + +pub const fn mask_vote_hook_index(index: u64) -> u64 { + FAILED_VOTE_HOOK_MASK | (index << BITS_RESERVED_FOR_REPLY_TYPE) +} + +pub const fn pre_propose_module_instantiation_id() -> u64 { + PRE_PROPOSE_MODULE_INSTANTIATION_ID +} + +pub const fn failed_pre_propose_module_hook_id() -> u64 { + FAILED_PRE_PROPOSE_MODULE_HOOK_ID +} + +pub mod error { + use thiserror::Error; + + #[derive(Error, Debug, PartialEq, Eq)] + pub enum TagError { + #[error("Unknown reply id ({id}).")] + UnknownReplyId { id: u64 }, + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_tagged_reply_id() { + // max u61 + let proposal_id_max: u64 = 2_u64.pow(61) - 1; + let proposal_hook_idx = 1234; + let vote_hook_idx = 4321; + + let m_proposal_id = mask_proposal_execution_proposal_id(proposal_id_max); + let m_proposal_hook_idx = mask_proposal_hook_index(proposal_hook_idx); + let m_vote_hook_idx = mask_vote_hook_index(vote_hook_idx); + + assert_eq!( + TaggedReplyId::new(m_proposal_id).unwrap(), + TaggedReplyId::FailedProposalExecution(proposal_id_max) + ); + assert_eq!( + TaggedReplyId::new(m_proposal_hook_idx).unwrap(), + TaggedReplyId::FailedProposalHook(proposal_hook_idx) + ); + assert_eq!( + TaggedReplyId::new(m_vote_hook_idx).unwrap(), + TaggedReplyId::FailedVoteHook(vote_hook_idx) + ); + assert_eq!( + TaggedReplyId::new(0b110).unwrap_err(), + error::TagError::UnknownReplyId { id: 0b110 } + ); + } +} diff --git a/packages/dao-voting/src/status.rs b/packages/dao-voting/src/status.rs new file mode 100644 index 000000000..3b75af978 --- /dev/null +++ b/packages/dao-voting/src/status.rs @@ -0,0 +1,32 @@ +use cosmwasm_schema::cw_serde; + +#[cw_serde] +#[derive(Copy)] +pub enum Status { + /// The proposal is open for voting. + Open, + /// The proposal has been rejected. + Rejected, + /// The proposal has been passed but has not been executed. + Passed, + /// The proposal has been passed and executed. + Executed, + /// The proposal has failed or expired and has been closed. A + /// proposal deposit refund has been issued if applicable. + Closed, + /// The proposal's execution failed. + ExecutionFailed, +} + +impl std::fmt::Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Status::Open => write!(f, "open"), + Status::Rejected => write!(f, "rejected"), + Status::Passed => write!(f, "passed"), + Status::Executed => write!(f, "executed"), + Status::Closed => write!(f, "closed"), + Status::ExecutionFailed => write!(f, "execution_failed"), + } + } +} diff --git a/packages/dao-voting/src/threshold.rs b/packages/dao-voting/src/threshold.rs new file mode 100644 index 000000000..f22fb9849 --- /dev/null +++ b/packages/dao-voting/src/threshold.rs @@ -0,0 +1,203 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Decimal, Uint128}; + +use thiserror::Error; + +/// 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. +#[cw_serde] +pub enum ActiveThreshold { + /// The absolute number of tokens that must be staked for the + /// module to be active. + AbsoluteCount { count: Uint128 }, + /// The percentage of tokens that must be staked for the module to + /// be active. Computed as `staked / total_supply`. + Percentage { percent: Decimal }, +} + +#[cw_serde] +pub struct ActiveThresholdResponse { + pub active_threshold: Option, +} + +#[derive(Error, Debug, PartialEq, Eq)] +pub enum ThresholdError { + #[error("Required threshold cannot be zero")] + ZeroThreshold {}, + + #[error("Not possible to reach required (passing) threshold")] + UnreachableThreshold {}, +} + +/// A percentage of voting power that must vote yes for a proposal to +/// pass. An example of why this is needed: +/// +/// If a user specifies a 60% passing threshold, and there are 10 +/// voters they likely expect that proposal to pass when there are 6 +/// yes votes. This implies that the condition for passing should be +/// `vote_weights >= total_votes * threshold`. +/// +/// With this in mind, how should a user specify that they would like +/// proposals to pass if the majority of voters choose yes? Selecting +/// a 50% passing threshold with those rules doesn't properly cover +/// that case as 5 voters voting yes out of 10 would pass the +/// proposal. Selecting 50.0001% or or some variation of that also +/// does not work as a very small yes vote which technically makes the +/// majority yes may not reach that threshold. +/// +/// To handle these cases we provide both a majority and percent +/// option for all percentages. If majority is selected passing will +/// be determined by `yes > total_votes * 0.5`. If percent is selected +/// passing is determined by `yes >= total_votes * percent`. +/// +/// In both of these cases a proposal with only abstain votes must +/// fail. This requires a special case passing logic. +#[cw_serde] +#[derive(Copy)] +pub enum PercentageThreshold { + /// The majority of voters must vote yes for the proposal to pass. + Majority {}, + /// A percentage of voting power >= percent must vote yes for the + /// proposal to pass. + Percent(Decimal), +} + +/// The ways a proposal may reach its passing / failing threshold. +#[cw_serde] +pub enum Threshold { + /// Declares a percentage of the total weight that must cast Yes + /// votes in order for a proposal to pass. See + /// `ThresholdResponse::AbsolutePercentage` in the cw3 spec for + /// details. + AbsolutePercentage { percentage: PercentageThreshold }, + + /// Declares a `quorum` of the total votes that must participate + /// in the election in order for the vote to be considered at all. + /// See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for + /// details. + ThresholdQuorum { + threshold: PercentageThreshold, + quorum: PercentageThreshold, + }, + + /// An absolute number of votes needed for something to cross the + /// threshold. Useful for multisig style voting. + AbsoluteCount { threshold: Uint128 }, +} + +/// Asserts that the 0.0 < percent <= 1.0 +fn validate_percentage(percent: &PercentageThreshold) -> Result<(), ThresholdError> { + if let PercentageThreshold::Percent(percent) = percent { + if percent.is_zero() { + Err(ThresholdError::ZeroThreshold {}) + } else if *percent > Decimal::one() { + Err(ThresholdError::UnreachableThreshold {}) + } else { + Ok(()) + } + } else { + Ok(()) + } +} + +/// Asserts that a quorum <= 1. Quorums may be zero, to enable plurality-style voting. +pub fn validate_quorum(quorum: &PercentageThreshold) -> Result<(), ThresholdError> { + match quorum { + PercentageThreshold::Majority {} => Ok(()), + PercentageThreshold::Percent(quorum) => { + if *quorum > Decimal::one() { + Err(ThresholdError::UnreachableThreshold {}) + } else { + Ok(()) + } + } + } +} + +impl Threshold { + /// Validates the threshold. + /// + /// - Quorums must never be over 100%. + /// - Passing thresholds must never be over 100%, nor be 0%. + /// - Absolute count thresholds must be non-zero. + pub fn validate(&self) -> Result<(), ThresholdError> { + match self { + Threshold::AbsolutePercentage { + percentage: percentage_needed, + } => validate_percentage(percentage_needed), + Threshold::ThresholdQuorum { threshold, quorum } => { + validate_percentage(threshold)?; + validate_quorum(quorum) + } + Threshold::AbsoluteCount { threshold } => { + if threshold.is_zero() { + Err(ThresholdError::ZeroThreshold {}) + } else { + Ok(()) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! p { + ($x:expr ) => { + PercentageThreshold::Percent(Decimal::percent($x)) + }; + } + + #[test] + fn test_threshold_validation() { + let t = Threshold::AbsoluteCount { + threshold: Uint128::zero(), + }; + assert_eq!(t.validate().unwrap_err(), ThresholdError::ZeroThreshold {}); + + let t = Threshold::AbsolutePercentage { percentage: p!(0) }; + assert_eq!(t.validate().unwrap_err(), ThresholdError::ZeroThreshold {}); + + let t = Threshold::AbsolutePercentage { + percentage: p!(101), + }; + assert_eq!( + t.validate().unwrap_err(), + ThresholdError::UnreachableThreshold {} + ); + + let t = Threshold::AbsolutePercentage { + percentage: p!(100), + }; + t.validate().unwrap(); + + let t = Threshold::ThresholdQuorum { + threshold: p!(101), + quorum: p!(0), + }; + assert_eq!( + t.validate().unwrap_err(), + ThresholdError::UnreachableThreshold {} + ); + + let t = Threshold::ThresholdQuorum { + threshold: p!(100), + quorum: p!(0), + }; + t.validate().unwrap(); + + let t = Threshold::ThresholdQuorum { + threshold: p!(100), + quorum: p!(101), + }; + assert_eq!( + t.validate().unwrap_err(), + ThresholdError::UnreachableThreshold {} + ); + } +} diff --git a/packages/dao-voting/src/voting.rs b/packages/dao-voting/src/voting.rs new file mode 100644 index 000000000..32f1e1e46 --- /dev/null +++ b/packages/dao-voting/src/voting.rs @@ -0,0 +1,454 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Decimal, Deps, StdResult, Uint128, Uint256}; +use cw_utils::Duration; +use dao_interface::voting; + +use crate::threshold::PercentageThreshold; + +// We multiply by this when calculating needed_votes in order to round +// up properly. +const PRECISION_FACTOR: u128 = 10u128.pow(9); + +#[cw_serde] +pub struct Votes { + pub yes: Uint128, + pub no: Uint128, + pub abstain: Uint128, +} + +#[cw_serde] +#[derive(Copy)] +#[repr(u8)] +pub enum Vote { + /// Marks support for the proposal. + Yes, + /// Marks opposition to the proposal. + No, + /// Marks participation but does not count towards the ratio of + /// support / opposed. + Abstain, +} + +pub enum VoteCmp { + Greater, + Geq, +} + +/// Compares `votes` with `total_power * passing_percentage`. The +/// comparison function used depends on the `VoteCmp` variation +/// selected. +/// +/// !!NOTE!! THIS FUNCTION DOES NOT ROUND UP. +/// +/// For example, the following assertion will succede: +/// +/// ```rust +/// use dao_voting::voting::{compare_vote_count, VoteCmp}; +/// use cosmwasm_std::{Uint128, Decimal}; +/// fn test() { +/// assert!(compare_vote_count( +/// Uint128::new(7), +/// VoteCmp::Greater, +/// Uint128::new(13), +/// Decimal::from_ratio(7u64, 13u64) +/// )); +/// } +/// ``` +/// +/// This is because `7 * (7/13)` is `6.999...` after rounding. You +/// MUST ensure this is the behavior you want when calling this +/// function. +/// +/// For our current purposes this is OK as the only place we use the +/// `Greater` comparason is when looking to see if no votes have +/// reached `> (1 - threshold)` and thus made the proposal +/// unpassable. As threshold will be a rounded down version of the +/// infinite percision real version `1 - threshold` will actually be a +/// higher magnitured than the real one meaning that we won't ever be +/// in the position of simeltaniously reporting a proposal as both +/// rejected and passed. +/// +pub fn compare_vote_count( + votes: Uint128, + cmp: VoteCmp, + total_power: Uint128, + passing_percentage: Decimal, +) -> bool { + let votes = votes.full_mul(PRECISION_FACTOR); + let total_power = total_power.full_mul(PRECISION_FACTOR); + let threshold = total_power.multiply_ratio( + passing_percentage.atomics(), + Uint256::from(10u64).pow(passing_percentage.decimal_places()), + ); + match cmp { + VoteCmp::Greater => votes > threshold, + VoteCmp::Geq => votes >= threshold, + } +} + +pub fn does_vote_count_pass( + vote_weights: Uint128, + options: Uint128, + percent: PercentageThreshold, +) -> bool { + // Don't pass proposals if all the votes are abstain. + if options.is_zero() { + return false; + } + match percent { + PercentageThreshold::Majority {} => vote_weights.full_mul(2u64) > options.into(), + PercentageThreshold::Percent(percent) => { + compare_vote_count(vote_weights, VoteCmp::Geq, options, percent) + } + } +} + +pub fn does_vote_count_fail( + no_votes: Uint128, + options: Uint128, + percent: PercentageThreshold, +) -> bool { + // All abstain votes should result in a rejected proposal. + if options.is_zero() { + return true; + } + match percent { + PercentageThreshold::Majority {} => { + // Fails if no votes have >= half of all votes. + no_votes.full_mul(2u64) >= options.into() + } + PercentageThreshold::Percent(percent) => compare_vote_count( + no_votes, + VoteCmp::Greater, + options, + Decimal::one() - percent, + ), + } +} + +impl Votes { + /// Constructs an zero'd out votes struct. + pub fn zero() -> Self { + Self { + yes: Uint128::zero(), + no: Uint128::zero(), + abstain: Uint128::zero(), + } + } + + /// Constructs a vote with a specified number of yes votes. Used + /// for testing. + #[cfg(test)] + pub fn with_yes(yes: Uint128) -> Self { + Self { + yes, + no: Uint128::zero(), + abstain: Uint128::zero(), + } + } + + /// Adds a vote to the votes. + pub fn add_vote(&mut self, vote: Vote, power: Uint128) { + match vote { + Vote::Yes => self.yes += power, + Vote::No => self.no += power, + Vote::Abstain => self.abstain += power, + } + } + + /// Removes a vote from the votes. The vote being removed must + /// have been previously added or this method will cause an + /// overflow. + pub fn remove_vote(&mut self, vote: Vote, power: Uint128) { + match vote { + Vote::Yes => self.yes -= power, + Vote::No => self.no -= power, + Vote::Abstain => self.abstain -= power, + } + } + + /// Computes the total number of votes cast. + /// + /// NOTE: The total number of votes avaliable from a voting module + /// is a `Uint128`. As it is not possible to vote twice we know + /// that the sum of votes must be <= 2^128 and can safely return a + /// `Uint128` from this function. A missbehaving voting power + /// module may break this invariant. + pub fn total(&self) -> Uint128 { + self.yes + self.no + self.abstain + } +} + +impl std::fmt::Display for Vote { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Vote::Yes => write!(f, "yes"), + Vote::No => write!(f, "no"), + Vote::Abstain => write!(f, "abstain"), + } + } +} + +/// A height of None will query for the current block height. +pub fn get_voting_power( + deps: Deps, + address: Addr, + dao: &Addr, + height: Option, +) -> StdResult { + let response: voting::VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( + dao, + &voting::Query::VotingPowerAtHeight { + address: address.into_string(), + height, + }, + )?; + Ok(response.power) +} + +/// A height of None will query for the current block height. +pub fn get_total_power(deps: Deps, dao: &Addr, height: Option) -> StdResult { + let response: voting::TotalPowerAtHeightResponse = deps + .querier + .query_wasm_smart(dao, &voting::Query::TotalPowerAtHeight { height })?; + Ok(response.power) +} + +/// Validates that the min voting period is less than the max voting +/// period. Passes arguments through the function. +pub fn validate_voting_period( + min: Option, + max: Duration, +) -> Result<(Option, Duration), crate::error::VotingError> { + let min = min + .map(|min| { + let valid = match (min, max) { + (Duration::Time(min), Duration::Time(max)) => min <= max, + (Duration::Height(min), Duration::Height(max)) => min <= max, + _ => return Err(crate::error::VotingError::DurationUnitsConflict {}), + }; + if valid { + Ok(min) + } else { + Err(crate::error::VotingError::InvalidMinVotingPeriod {}) + } + }) + .transpose()?; + + Ok((min, max)) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn count_votes() { + let mut votes = Votes::with_yes(Uint128::new(5)); + votes.add_vote(Vote::No, Uint128::new(10)); + votes.add_vote(Vote::Yes, Uint128::new(30)); + votes.add_vote(Vote::Abstain, Uint128::new(40)); + + assert_eq!(votes.total(), Uint128::new(5 + 10 + 30 + 40)); + assert_eq!(votes.yes, Uint128::new(35)); + assert_eq!(votes.no, Uint128::new(10)); + assert_eq!(votes.abstain, Uint128::new(40)); + } + + #[test] + fn vote_comparisons() { + assert!(!compare_vote_count( + Uint128::new(7), + VoteCmp::Geq, + Uint128::new(15), + Decimal::percent(50) + )); + assert!(!compare_vote_count( + Uint128::new(7), + VoteCmp::Greater, + Uint128::new(15), + Decimal::percent(50) + )); + + assert!(compare_vote_count( + Uint128::new(7), + VoteCmp::Geq, + Uint128::new(14), + Decimal::percent(50) + )); + assert!(!compare_vote_count( + Uint128::new(7), + VoteCmp::Greater, + Uint128::new(14), + Decimal::percent(50) + )); + + assert!(compare_vote_count( + Uint128::new(7), + VoteCmp::Geq, + Uint128::new(13), + Decimal::from_ratio(7u64, 13u64) + )); + + assert!(!compare_vote_count( + Uint128::new(6), + VoteCmp::Greater, + Uint128::new(13), + Decimal::one() - Decimal::from_ratio(7u64, 13u64) + )); + assert!(compare_vote_count( + Uint128::new(7), + VoteCmp::Greater, + Uint128::new(13), + Decimal::from_ratio(7u64, 13u64) + )); + + assert!(!compare_vote_count( + Uint128::new(4), + VoteCmp::Geq, + Uint128::new(9), + Decimal::percent(50) + )) + } + + #[test] + fn more_votes_tests() { + assert!(compare_vote_count( + Uint128::new(1), + VoteCmp::Geq, + Uint128::new(3), + Decimal::permille(333) + )); + + assert!(!compare_vote_count( + Uint128::new(1), + VoteCmp::Geq, + Uint128::new(3), + Decimal::permille(334) + )); + assert!(compare_vote_count( + Uint128::new(2), + VoteCmp::Geq, + Uint128::new(3), + Decimal::permille(334) + )); + + assert!(compare_vote_count( + Uint128::new(11), + VoteCmp::Geq, + Uint128::new(30), + Decimal::permille(333) + )); + + assert!(compare_vote_count( + Uint128::new(15), + VoteCmp::Geq, + Uint128::new(30), + Decimal::permille(500) + )); + assert!(!compare_vote_count( + Uint128::new(15), + VoteCmp::Greater, + Uint128::new(30), + Decimal::permille(500) + )); + + assert!(compare_vote_count( + Uint128::new(0), + VoteCmp::Geq, + Uint128::new(0), + Decimal::permille(500) + )); + assert!(!compare_vote_count( + Uint128::new(0), + VoteCmp::Greater, + Uint128::new(0), + Decimal::permille(500) + )); + + assert!(!compare_vote_count( + Uint128::new(0), + VoteCmp::Geq, + Uint128::new(1), + Decimal::permille(1) + )); + assert!(!compare_vote_count( + Uint128::new(0), + VoteCmp::Greater, + Uint128::new(1), + Decimal::permille(1) + )); + + assert!(compare_vote_count( + Uint128::new(1), + VoteCmp::Geq, + Uint128::new(1), + Decimal::permille(1) + )); + assert!(compare_vote_count( + Uint128::new(1), + VoteCmp::Greater, + Uint128::new(1), + Decimal::permille(1) + )); + + assert!(!compare_vote_count( + Uint128::new(0), + VoteCmp::Geq, + Uint128::new(1), + Decimal::permille(999) + )); + assert!(!compare_vote_count( + Uint128::new(0), + VoteCmp::Greater, + Uint128::new(1), + Decimal::permille(999) + )); + } + + #[test] + fn tricky_vote_counts() { + let threshold = Decimal::percent(50); + for count in 1..50_000 { + assert!(compare_vote_count( + Uint128::new(count), + VoteCmp::Geq, + Uint128::new(count * 2), + threshold + )); + assert!(!compare_vote_count( + Uint128::new(count), + VoteCmp::Greater, + Uint128::new(count * 2), + threshold + )) + } + + // Zero votes out of zero total power meet any threshold. When + // Geq is used. Always fail otherwise. + assert!(compare_vote_count( + Uint128::zero(), + VoteCmp::Geq, + Uint128::new(1), + Decimal::percent(0) + )); + assert!(compare_vote_count( + Uint128::zero(), + VoteCmp::Geq, + Uint128::new(0), + Decimal::percent(0) + )); + assert!(!compare_vote_count( + Uint128::zero(), + VoteCmp::Greater, + Uint128::new(1), + Decimal::percent(0) + )); + assert!(!compare_vote_count( + Uint128::zero(), + VoteCmp::Greater, + Uint128::new(0), + Decimal::percent(0) + )) + } +} diff --git a/scripts/create-v2-dao-native-voting.sh b/scripts/create-v2-dao-native-voting.sh new file mode 100755 index 000000000..3c0505d2e --- /dev/null +++ b/scripts/create-v2-dao-native-voting.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +if [ "$1" = "" ] +then + echo "Usage: $0 junod key name required as an argument, e.g. ./create-v2-dao-native-voting.sh mykeyname" + exit +fi + +export CHAIN_ID="uni-5" +export TESTNET_NAME="uni-5" +export DENOM="ujunox" +export BECH32_HRP="juno" +export WASMD_VERSION="v0.20.0" +export JUNOD_VERSION="v2.1.0" +export CONFIG_DIR=".juno" +export BINARY="junod" + +export COSMJS_VERSION="v0.26.5" +export GENESIS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/genesis.json" +export PERSISTENT_PEERS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/persistent_peers.txt" +export SEEDS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/seeds.txt" + +export RPC="https://rpc.uni.juno.deuslabs.fi:443" +export LCD="https://lcd.uni.juno.deuslabs.fi" +export FAUCET="https://faucet.uni.juno.deuslabs.fi" + +export COSMOVISOR_VERSION="v0.1.0" +export COSMOVISOR_HOME=$HOME/.juno +export COSMOVISOR_NAME=junod + +export TXFLAG="--chain-id ${CHAIN_ID} --gas-prices 0.025ujunox --gas auto --gas-adjustment 1.3 --broadcast-mode block" +export NODE="https://juno-testnet-rpc.polkachu.com:443" + +KEY_NAME=$1 +MODULE_MSG='{ + "allow_revoting": false, + "max_voting_period": { + "time": 604800 + }, + "close_proposal_on_execution_failure": true, + "pre_propose_info": {"AnyoneMayPropose":{}}, + "only_members_execute": true, + "threshold": { + "threshold_quorum": { + "quorum": { + "percent": "0.20" + }, + "threshold": { + "majority": {} + } + } + } + }' + +ENCODED_PROP_MESSAGE=`echo -n $MODULE_MSG | tr -d '[:space:]' | openssl base64 | tr -d '[:space:]'` +echo -e '\nENCODED PROP MESSAGE' +echo $ENCODED_PROP_MESSAGE + +VOTING_MSG='{"owner":{"core_module":{}},"denom":"ujunox"}' + +ENCODED_VOTING_MESSAGE=`echo $VOTING_MSG | tr -d '[:space:]' | openssl base64 | tr -d '[:space:]'` +echo -e '\nENCODED VOTING MESSAGE' +echo $ENCODED_VOTING_MESSAGE + +CW_CORE_INIT='{ + "admin": "juno12jphyrpd82v8s8cq4n0nu7fa9qcx5hppdwevulhqdhyqu7vkrscs3sv2ct", + "automatically_add_cw20s": true, + "automatically_add_cw721s": true, + "description": "V2 DAO", + "name": "V2 DAO", + "proposal_modules_instantiate_info": [ + { + "admin": { + "core_module": {} + }, + "code_id": 696, + "label": "v2 dao", + "msg": "'$ENCODED_PROP_MESSAGE'" + } + ], + "voting_module_instantiate_info": { + "admin": { + "core_module": {} + }, + "code_id": 799, + "label": "test_v2_dao-cw-native-voting", + "msg": "'$ENCODED_VOTING_MESSAGE'" + } +}' + +# encode +CW_CORE_STRIPPED=`echo -n $CW_CORE_INIT | tr -d '[:space:]'` +echo -e 'CW-CORE INSTANTIATE MESSAGE:\n' +echo -$CW_CORE_STRIPPED +CW_CORE_ENCODED=`echo -n $CW_CORE_STRIPPED | openssl base64 | tr -d '[:space:]'` +echo -e '\nCW-CORE ENCODED MESSAGE:\n' +echo $CW_CORE_ENCODED + +# init with factory +INIT_MSG='{"instantiate_contract_with_self_admin":{"code_id":695, "label": "v2 subDAO subDAO", "instantiate_msg":"'$CW_CORE_ENCODED'"}}' + +# instantiate with factory +echo 'instantiating cw-core with factory' +junod tx wasm execute juno143quaa25ynh6j5chhqh8w6lj647l4kpu5r6aqxvzul8du0mwyvns2g6u45 "$INIT_MSG" --from bluenote --node $NODE $TXFLAG diff --git a/scripts/create-v2-dao-prepropose.sh b/scripts/create-v2-dao-prepropose.sh new file mode 100755 index 000000000..0753886e8 --- /dev/null +++ b/scripts/create-v2-dao-prepropose.sh @@ -0,0 +1,140 @@ +#!/bin/bash + +if [ "$1" = "" ] +then + echo "Usage: $0 junod key name required as an argument, e.g. ./create-v2-dao-native-voting.sh mykeyname" + exit +fi + +export CHAIN_ID="uni-5" +export TESTNET_NAME="uni-5" +export DENOM="ujunox" +export BECH32_HRP="juno" +export WASMD_VERSION="v0.20.0" +export JUNOD_VERSION="v2.1.0" +export CONFIG_DIR=".juno" +export BINARY="junod" + +export COSMJS_VERSION="v0.26.5" +export GENESIS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/genesis.json" +export PERSISTENT_PEERS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/persistent_peers.txt" +export SEEDS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/seeds.txt" + +export RPC="https://rpc.uni.juno.deuslabs.fi:443" +export LCD="https://lcd.uni.juno.deuslabs.fi" +export FAUCET="https://faucet.uni.juno.deuslabs.fi" + +export COSMOVISOR_VERSION="v0.1.0" +export COSMOVISOR_HOME=$HOME/.juno +export COSMOVISOR_NAME=junod + +export TXFLAG="--chain-id ${CHAIN_ID} --gas-prices 0.025ujunox --gas auto --gas-adjustment 1.3 --broadcast-mode block" +export NODE="https://juno-testnet-rpc.polkachu.com:443" + +KEY_NAME=$1 + +PRE_PROPOSE_INSTANTIATE_MSG='{ + "deposit_info": { + "denom": { + "token": { + "denom": { + "native": "ujunox" + } + } + }, + "amount": "1", + "refund_policy": "always" + }, + "open_proposal_submission": false, + "extension": {} + }' + +# NO DEPOSIT + + # PRE_PROPOSE_INSTANTIATE_MSG='{ + # "open_proposal_submission": false, + # "extension": {} + # }' + +ENCODED_PRE_PROPOSE=`echo -n $PRE_PROPOSE_INSTANTIATE_MSG | tr -d '[:space:]' | openssl base64 | tr -d '[:space:]'` +echo -e '\nENCODED PREPROPOSE MESSAGE' +echo $ENCODED_PRE_PROPOSE + +MODULE_MSG='{ + "allow_revoting": false, + "max_voting_period": { + "time": 604800 + }, + "close_proposal_on_execution_failure": true, + "pre_propose_info": {"ModuleMayPropose":{ + "info": { + "admin": { + "core_module": {} + }, + "code_id": 845, + "label": "pre propose module", + "msg": "'$ENCODED_PRE_PROPOSE'" + }}}, + "only_members_execute": true, + "threshold": { + "threshold_quorum": { + "quorum": { + "percent": "0.20" + }, + "threshold": { + "majority": {} + } + } + } + }' + +ENCODED_PROP_MESSAGE=`echo -n $MODULE_MSG | tr -d '[:space:]' | openssl base64 | tr -d '[:space:]'` +echo -e '\nENCODED PROP MESSAGE' +echo $ENCODED_PROP_MESSAGE + +VOTING_MSG='{"cw4_group_code_id":701,"initial_members":[{"addr":"juno1873my89qs478e56austefw0ewpp774xmq5m4xv","weight":30},{"addr":"juno16mrjtqffn3awme2eczhlpwzj7mnatkeluvhj6c","weight":1}]}' + +ENCODED_VOTING_MESSAGE=`echo $VOTING_MSG | tr -d '[:space:]' | openssl base64 | tr -d '[:space:]'` +echo -e '\nENCODED VOTING MESSAGE' +echo $ENCODED_VOTING_MESSAGE + +CW_CORE_INIT='{ + "admin": "juno12jphyrpd82v8s8cq4n0nu7fa9qcx5hppdwevulhqdhyqu7vkrscs3sv2ct", + "automatically_add_cw20s": true, + "automatically_add_cw721s": true, + "description": "V2 DAO", + "name": "V2 DAO", + "proposal_modules_instantiate_info": [ + { + "admin": { + "core_module": {} + }, + "code_id": 844, + "label": "v2 dao", + "msg": "'$ENCODED_PROP_MESSAGE'" + } + ], + "voting_module_instantiate_info": { + "admin": { + "core_module": {} + }, + "code_id": 846, + "label": "test_v2_dao-cw4-voting", + "msg": "'$ENCODED_VOTING_MESSAGE'" + } +}' + +# encode +CW_CORE_STRIPPED=`echo -n $CW_CORE_INIT | tr -d '[:space:]'` +echo -e 'CW-CORE INSTANTIATE MESSAGE:\n' +echo -$CW_CORE_STRIPPED +CW_CORE_ENCODED=`echo -n $CW_CORE_STRIPPED | openssl base64 | tr -d '[:space:]'` +echo -e '\nCW-CORE ENCODED MESSAGE:\n' +echo $CW_CORE_ENCODED + +# init with factory +INIT_MSG='{"instantiate_contract_with_self_admin":{"code_id":843, "label": "v2 subDAO subDAO", "instantiate_msg":"'$CW_CORE_ENCODED'"}}' + +# instantiate with factory +echo 'instantiating cw-core with factory' +junod tx wasm execute juno143quaa25ynh6j5chhqh8w6lj647l4kpu5r6aqxvzul8du0mwyvns2g6u45 "$INIT_MSG" --from $KEY_NAME --node $NODE $TXFLAG diff --git a/scripts/create-v2-dao.sh b/scripts/create-v2-dao.sh new file mode 100755 index 000000000..3cccb3127 --- /dev/null +++ b/scripts/create-v2-dao.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +if [ "$1" = "" ] +then + echo "Usage: $0 junod key name required as an argument, e.g. ./create-v2-dao-native-voting.sh mykeyname" + exit +fi + +export CHAIN_ID="uni-5" +export TESTNET_NAME="uni-5" +export DENOM="ujunox" +export BECH32_HRP="juno" +export WASMD_VERSION="v0.20.0" +export JUNOD_VERSION="v2.1.0" +export CONFIG_DIR=".juno" +export BINARY="junod" + +export COSMJS_VERSION="v0.26.5" +export GENESIS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/genesis.json" +export PERSISTENT_PEERS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/persistent_peers.txt" +export SEEDS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/seeds.txt" + +export RPC="https://rpc.uni.juno.deuslabs.fi:443" +export LCD="https://lcd.uni.juno.deuslabs.fi" +export FAUCET="https://faucet.uni.juno.deuslabs.fi" + +export COSMOVISOR_VERSION="v0.1.0" +export COSMOVISOR_HOME=$HOME/.juno +export COSMOVISOR_NAME=junod + +export TXFLAG="--chain-id ${CHAIN_ID} --gas-prices 0.025ujunox --gas auto --gas-adjustment 1.3 --broadcast-mode block" +export NODE="https://juno-testnet-rpc.polkachu.com:443" + +KEY_NAME=$1 + +MODULE_MSG='{ + "allow_revoting": false, + "max_voting_period": { + "time": 604800 + }, + "close_proposal_on_execution_failure": true, + "pre_propose_info": {"AnyoneMayPropose":{}}, + "only_members_execute": true, + "threshold": { + "threshold_quorum": { + "quorum": { + "percent": "0.20" + }, + "threshold": { + "majority": {} + } + } + } + }' + +ENCODED_PROP_MESSAGE=`echo -n $MODULE_MSG | tr -d '[:space:]' | openssl base64 | tr -d '[:space:]'` +echo -e '\nENCODED PROP MESSAGE' +echo $ENCODED_PROP_MESSAGE + +VOTING_MSG='{"cw4_group_code_id":701,"initial_members":[{"addr":"juno1873my89qs478e56austefw0ewpp774xmq5m4xv","weight":30},{"addr":"juno16mrjtqffn3awme2eczhlpwzj7mnatkeluvhj6c","weight":1}]}' + +ENCODED_VOTING_MESSAGE=`echo $VOTING_MSG | tr -d '[:space:]' | openssl base64 | tr -d '[:space:]'` +echo -e '\nENCODED VOTING MESSAGE' +echo $ENCODED_VOTING_MESSAGE + +CW_CORE_INIT='{ + "admin": "juno12jphyrpd82v8s8cq4n0nu7fa9qcx5hppdwevulhqdhyqu7vkrscs3sv2ct", + "automatically_add_cw20s": true, + "automatically_add_cw721s": true, + "description": "V2 DAO", + "name": "V2 DAO", + "proposal_modules_instantiate_info": [ + { + "admin": { + "core_module": {} + }, + "code_id": 696, + "label": "v2 dao", + "msg": "'$ENCODED_PROP_MESSAGE'" + } + ], + "voting_module_instantiate_info": { + "admin": { + "core_module": {} + }, + "code_id": 698, + "label": "test_v2_dao-cw4-voting", + "msg": "'$ENCODED_VOTING_MESSAGE'" + } +}' + +# encode +CW_CORE_STRIPPED=`echo -n $CW_CORE_INIT | tr -d '[:space:]'` +echo -e 'CW-CORE INSTANTIATE MESSAGE:\n' +echo -$CW_CORE_STRIPPED +CW_CORE_ENCODED=`echo -n $CW_CORE_STRIPPED | openssl base64 | tr -d '[:space:]'` +echo -e '\nCW-CORE ENCODED MESSAGE:\n' +echo $CW_CORE_ENCODED + +# init with factory +INIT_MSG='{"instantiate_contract_with_self_admin":{"code_id":695, "label": "v2 subDAO subDAO", "instantiate_msg":"'$CW_CORE_ENCODED'"}}' + +# instantiate with factory +echo 'instantiating cw-core with factory' +junod tx wasm execute juno143quaa25ynh6j5chhqh8w6lj647l4kpu5r6aqxvzul8du0mwyvns2g6u45 "$INIT_MSG" --from $KEY_NAME --node $NODE $TXFLAG diff --git a/scripts/list-exports.mjs b/scripts/list-exports.mjs new file mode 100644 index 000000000..311631f61 --- /dev/null +++ b/scripts/list-exports.mjs @@ -0,0 +1,6 @@ +import fs from 'fs' + +const wasm = fs.readFileSync(process.argv[2]) +const module = await WebAssembly.compile(wasm) + +console.log(WebAssembly.Module.exports(module).map(({name}) => name)) diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100644 index 000000000..ba42f086e --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -o errexit -o nounset -o pipefail +command -v shellcheck >/dev/null && shellcheck "$0" + +function print_usage() { + echo "Usage: $0 [-h|--help]" + echo "Publishes crates to crates.io." +} + +if [ $# = 1 ] && { [ "$1" = "-h" ] || [ "$1" = "--help" ] ; } +then + print_usage + exit 1 +fi + +START_DIR=$(pwd) + +# Publishing cargo workspaces with cyclic dependencies is very tricky. +# Dev-dependencies are often cyclic, so we need to publish without them. +# There is ongoing discussion about this issue: https://github.com/rust-lang/cargo/issues/4242 +# +# In the meantime, we are using cargo-hack to publish our crates. +# This temporarily removes dev dependencies from Cargo.toml and publishes the crate. +# https://github.com/taiki-e/cargo-hack +# +# Install cargo-hack to run this script. `cargo install cargo-hack` + +# Crates must be published in the correct order, as some depend on others. +# We start with publish packages, aside from dao-testing which must be published last. + +# Packages +cd packages/cw-denom +cargo publish +cd "$START_DIR" + +cd packages/cw-hooks +cargo publish +cd "$START_DIR" + +cd packages/cw-wormhole +cargo publish +cd "$START_DIR" + +cd packages/cw-stake-tracker +cargo publish +cd "$START_DIR" + +cd packages/cw-paginate-storage +cargo publish +cd "$START_DIR" + +sleep 120 + +cd packages/cw721-controllers +cargo publish +cd "$START_DIR" + +cd packages/dao-interface +cargo publish +cd "$START_DIR" + +cd packages/dao-dao-macros +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd packages/dao-voting +cargo publish +cd "$START_DIR" + +cd packages/dao-vote-hooks +cargo publish +cd "$START_DIR" + +sleep 120 + +cd packages/dao-proposal-hooks +cargo publish +cd "$START_DIR" + +cd packages/dao-pre-propose-base +cargo publish +cd "$START_DIR" + +# Test contracts +cd test-contracts/dao-proposal-sudo +cargo publish +cd "$START_DIR" + +cd test-contracts/dao-voting-cw20-balance +cargo publish +cd "$START_DIR" + +cd test-contracts/dao-proposal-hook-counter +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +sleep 120 + +# Contracts +cd contracts/external/cw-token-swap +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/external/cw-vesting +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/external/cw-payroll-factory +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/pre-propose/dao-pre-propose-single +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +sleep 120 + +cd contracts/pre-propose/dao-pre-propose-multiple +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/pre-propose/dao-pre-propose-approval-single +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/pre-propose/dao-pre-propose-approver +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/proposal/dao-proposal-single +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/proposal/dao-proposal-multiple +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +sleep 120 + +cd contracts/proposal/dao-proposal-condorcet +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/staking/cw20-stake +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/staking/cw20-stake-external-rewards +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/staking/cw20-stake-reward-distributor +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/voting/dao-voting-cw4 +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +sleep 120 + +cd contracts/voting/dao-voting-cw20-staked +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/voting/dao-voting-cw721-staked +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/voting/dao-voting-native-staked +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/dao-dao-core +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd contracts/external/cw-admin-factory +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +sleep 120 + +# TODO re-enable when ready +# cd contracts/external/cw-fund-distributor +# cargo hack publish --no-dev-deps --allow-dirty +# cd "$START_DIR" + +cd contracts/external/dao-migrator +cargo hack publish --no-dev-deps --allow-dirty +cd "$START_DIR" + +cd packages/dao-testing +cargo publish +cd "$START_DIR" + +echo "Everything is published!" diff --git a/scripts/schema.sh b/scripts/schema.sh new file mode 100755 index 000000000..08fa7373e --- /dev/null +++ b/scripts/schema.sh @@ -0,0 +1,64 @@ +START_DIR=$(pwd) + +# ${f <-- from variable f +# ## <-- greedy front trim +# * <-- matches anything +# / <-- until the last '/' +# } +# + +echo "generating schema for dao-dao-core" +cd contracts/dao-dao-core +cargo run --example schema > /dev/null +rm -rf ./schema/raw +cd "$START_DIR" + +for f in ./contracts/voting/* +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/proposal/* +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/staking/* +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/pre-propose/* +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/external/* +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 diff --git a/scripts/update-v2-subdaos.sh b/scripts/update-v2-subdaos.sh new file mode 100755 index 000000000..84421d230 --- /dev/null +++ b/scripts/update-v2-subdaos.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +if [ "$1" = "" ] +then + echo "Usage: $0 junod key name required as an argument, e.g. ./create-v2-dao-native-voting.sh mykeyname" + exit +fi + +export CHAIN_ID="uni-5" +export TESTNET_NAME="uni-5" +export DENOM="ujunox" +export BECH32_HRP="juno" +export WASMD_VERSION="v0.20.0" +export JUNOD_VERSION="v2.1.0" +export CONFIG_DIR=".juno" +export BINARY="junod" + +export COSMJS_VERSION="v0.26.5" +export GENESIS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/genesis.json" +export PERSISTENT_PEERS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/persistent_peers.txt" +export SEEDS_URL="https://raw.githubusercontent.com/CosmosContracts/testnets/main/uni-2/seeds.txt" + +export RPC="https://rpc.uni.juno.deuslabs.fi:443" +export LCD="https://lcd.uni.juno.deuslabs.fi" +export FAUCET="https://faucet.uni.juno.deuslabs.fi" + +export COSMOVISOR_VERSION="v0.1.0" +export COSMOVISOR_HOME=$HOME/.juno +export COSMOVISOR_NAME=junod + +export TXFLAG="--chain-id ${CHAIN_ID} --gas-prices 0.025ujunox --gas auto --gas-adjustment 1.3 --broadcast-mode block" +export NODE="https://juno-testnet-rpc.polkachu.com:443" + +UPDATE_SUBDAOS_MESSAGE='{"update_sub_daos": {"to_add": [{"addr": "juno1xvkad3623mnrhrse6y0atuj0sqk7ugveqxzu0xll64u230ugckaqvh2z92", "charter": "The test v2 subDAO."}], "to_remove":[]}}' +PARENT_DAO_ADDRESS="juno165enxtrex9lhukghct277umy3tcrvhj5p3rrnkw0q34rckgznekse8vlz7" +PROPOSAL_MODULE_ADDRESS="juno1gxst20k2k8xjwjjaw8vrg340zy9aqym7evk8gmut54nxevluey9s2x7tmy" +KEY_NAME=$1 + +ENCODED=`echo -n $UPDATE_SUBDAOS_MESSAGE | openssl base64 | tr -d '[:space:]'` + +EXECUTE_PROPOSE_MESSAGE='{"propose": { + "description": "update subdaos", + "msgs": [ + { + "wasm": { + "execute": { + "contract_addr": "'$PARENT_DAO_ADDRESS'", + "funds": [], + "msg": "'$ENCODED'" + } + } + } + ], + "title": "Update subDAOs list" + }}' + +echo $EXECUTE_PROPOSE_MESSAGE + +junod tx wasm execute $PROPOSAL_MODULE_ADDRESS "$EXECUTE_PROPOSE_MESSAGE" $TXFLAG --node $NODE --from $KEY_NAME + +VOTING_MESSAGE='{"vote": { + "proposal_id": 1, + "vote": "yes" +}}' + +junod tx wasm execute $PROPOSAL_MODULE_ADDRESS "$VOTING_MESSAGE" $TXFLAG --node $NODE --from $KEY_NAME + +EXECUTE_MSG='{"execute": {"proposal_id":1}}' + +junod tx wasm execute $PROPOSAL_MODULE_ADDRESS '{"execute": {"proposal_id":1}}' $TXFLAG --node $NODE --from $KEY_NAME + +junod query wasm contract-state smart $PARENT_DAO_ADDRESS '{"list_sub_daos":{}}' --node $NODE + +LIST_SUBDAOS='{"list_sub_daos":{}}' +junod query wasm contract $PARENT_DAO_ADDRESS '{"list_sub_daos":{}}'--node $NODE \ No newline at end of file diff --git a/test-contracts/dao-proposal-hook-counter/.cargo/config b/test-contracts/dao-proposal-hook-counter/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/test-contracts/dao-proposal-hook-counter/.gitignore b/test-contracts/dao-proposal-hook-counter/.gitignore new file mode 100644 index 000000000..dfdaaa6bc --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/.gitignore @@ -0,0 +1,15 @@ +# Build results +/target + +# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327) +.cargo-ok + +# Text file backups +**/*.rs.bk + +# macOS +.DS_Store + +# IDEs +*.iml +.idea diff --git a/test-contracts/dao-proposal-hook-counter/Cargo.toml b/test-contracts/dao-proposal-hook-counter/Cargo.toml new file mode 100644 index 000000000..5effd61ef --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "dao-proposal-hook-counter" +authors = ["Callum Anderson "] +description = "A DAO DAO test contract for counting proposal hook calls." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +thiserror = { workspace = true } +dao-proposal-hooks = { workspace = true } +dao-vote-hooks = { workspace = true } + +[dev-dependencies] +cw-hooks = { workspace = true } +cw20 = { workspace = true } +dao-voting-cw20-balance = { workspace = true } +cw20-base = { workspace = true } +cw-utils = { workspace = true } +dao-voting = { workspace = true } +dao-interface = { workspace = true } +dao-dao-core = { workspace = true } +dao-proposal-single = { workspace = true } +cw-multi-test = { workspace = true } diff --git a/test-contracts/dao-proposal-hook-counter/README.md b/test-contracts/dao-proposal-hook-counter/README.md new file mode 100644 index 000000000..b585ebe53 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/README.md @@ -0,0 +1,6 @@ +# Proposal Hooks Counter + +## Purely for testing for hooks integration. (DO NOT USE IN MAINNET) + +Contract that integrates with the new proposal hooks +system to test if they work and are reverted correctly. diff --git a/test-contracts/dao-proposal-hook-counter/examples/schema.rs b/test-contracts/dao-proposal-hook-counter/examples/schema.rs new file mode 100644 index 000000000..9e6ecbb3b --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/examples/schema.rs @@ -0,0 +1,17 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; + +use dao_proposal_hook_counter::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); +} diff --git a/test-contracts/dao-proposal-hook-counter/schema/execute_msg.json b/test-contracts/dao-proposal-hook-counter/schema/execute_msg.json new file mode 100644 index 000000000..59b28eff1 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/schema/execute_msg.json @@ -0,0 +1,122 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "proposal_hook" + ], + "properties": { + "proposal_hook": { + "$ref": "#/definitions/ProposalHookMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "vote_hook" + ], + "properties": { + "vote_hook": { + "$ref": "#/definitions/VoteHookMsg" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "ProposalHookMsg": { + "oneOf": [ + { + "type": "object", + "required": [ + "new_proposal" + ], + "properties": { + "new_proposal": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "proposal_status_changed" + ], + "properties": { + "proposal_status_changed": { + "type": "object", + "required": [ + "id", + "new_status", + "old_status" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "new_status": { + "type": "string" + }, + "old_status": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "VoteHookMsg": { + "oneOf": [ + { + "type": "object", + "required": [ + "new_vote" + ], + "properties": { + "new_vote": { + "type": "object", + "required": [ + "proposal_id", + "vote", + "voter" + ], + "properties": { + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "type": "string" + }, + "voter": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/test-contracts/dao-proposal-hook-counter/schema/instantiate_msg.json b/test-contracts/dao-proposal-hook-counter/schema/instantiate_msg.json new file mode 100644 index 000000000..89ab392c6 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/schema/instantiate_msg.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "should_error" + ], + "properties": { + "should_error": { + "type": "boolean" + } + } +} diff --git a/test-contracts/dao-proposal-hook-counter/schema/query_msg.json b/test-contracts/dao-proposal-hook-counter/schema/query_msg.json new file mode 100644 index 000000000..128ececef --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/schema/query_msg.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "vote_counter" + ], + "properties": { + "vote_counter": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "proposal_counter" + ], + "properties": { + "proposal_counter": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "status_changed_counter" + ], + "properties": { + "status_changed_counter": { + "type": "object" + } + }, + "additionalProperties": false + } + ] +} diff --git a/test-contracts/dao-proposal-hook-counter/src/contract.rs b/test-contracts/dao-proposal-hook-counter/src/contract.rs new file mode 100644 index 000000000..2a9163d02 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/src/contract.rs @@ -0,0 +1,106 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw2::set_contract_version; +use dao_proposal_hooks::ProposalHookMsg; +use dao_vote_hooks::VoteHookMsg; + +use crate::error::ContractError; +use crate::msg::{CountResponse, ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{Config, CONFIG, PROPOSAL_COUNTER, STATUS_CHANGED_COUNTER, VOTE_COUNTER}; + +const CONTRACT_NAME: &str = "crates.io:proposal-hooks-counter"; +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)?; + + let config = Config { + should_error: msg.should_error, + }; + CONFIG.save(deps.storage, &config)?; + PROPOSAL_COUNTER.save(deps.storage, &0)?; + VOTE_COUNTER.save(deps.storage, &0)?; + STATUS_CHANGED_COUNTER.save(deps.storage, &0)?; + Ok(Response::new().add_attribute("action", "instantiate")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.should_error { + return Err(ContractError::Unauthorized {}); + } + + match msg { + ExecuteMsg::ProposalHook(proposal_hook) => { + execute_proposal_hook(deps, env, info, proposal_hook) + } + ExecuteMsg::VoteHook(vote_hook) => execute_vote_hook(deps, env, info, vote_hook), + } +} + +pub fn execute_proposal_hook( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + proposal_hook: ProposalHookMsg, +) -> Result { + match proposal_hook { + ProposalHookMsg::NewProposal { .. } => { + let mut count = PROPOSAL_COUNTER.load(deps.storage)?; + count += 1; + PROPOSAL_COUNTER.save(deps.storage, &count)?; + } + ProposalHookMsg::ProposalStatusChanged { .. } => { + let mut count = STATUS_CHANGED_COUNTER.load(deps.storage)?; + count += 1; + STATUS_CHANGED_COUNTER.save(deps.storage, &count)?; + } + } + + Ok(Response::new().add_attribute("action", "proposal_hook")) +} + +pub fn execute_vote_hook( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + vote_hook: VoteHookMsg, +) -> Result { + match vote_hook { + VoteHookMsg::NewVote { .. } => { + let mut count = VOTE_COUNTER.load(deps.storage)?; + count += 1; + VOTE_COUNTER.save(deps.storage, &count)?; + } + } + + Ok(Response::new().add_attribute("action", "vote_hook")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::VoteCounter {} => to_binary(&CountResponse { + count: VOTE_COUNTER.load(deps.storage)?, + }), + QueryMsg::ProposalCounter {} => to_binary(&CountResponse { + count: PROPOSAL_COUNTER.load(deps.storage)?, + }), + QueryMsg::StatusChangedCounter {} => to_binary(&CountResponse { + count: STATUS_CHANGED_COUNTER.load(deps.storage)?, + }), + } +} diff --git a/test-contracts/dao-proposal-hook-counter/src/error.rs b/test-contracts/dao-proposal-hook-counter/src/error.rs new file mode 100644 index 000000000..dc19f1033 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/src/error.rs @@ -0,0 +1,11 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, +} diff --git a/test-contracts/dao-proposal-hook-counter/src/lib.rs b/test-contracts/dao-proposal-hook-counter/src/lib.rs new file mode 100644 index 000000000..8e58a2f17 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/test-contracts/dao-proposal-hook-counter/src/msg.rs b/test-contracts/dao-proposal-hook-counter/src/msg.rs new file mode 100644 index 000000000..825c57bf4 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/src/msg.rs @@ -0,0 +1,30 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use dao_proposal_hooks::ProposalHookMsg; +use dao_vote_hooks::VoteHookMsg; + +#[cw_serde] +pub struct InstantiateMsg { + pub should_error: bool, // Debug flag to test when hooks fail over +} + +#[cw_serde] +pub enum ExecuteMsg { + ProposalHook(ProposalHookMsg), + VoteHook(VoteHookMsg), +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(u64)] + VoteCounter {}, + #[returns(u64)] + ProposalCounter {}, + #[returns(u64)] + StatusChangedCounter {}, +} + +#[cw_serde] +pub struct CountResponse { + pub count: u64, +} diff --git a/test-contracts/dao-proposal-hook-counter/src/state.rs b/test-contracts/dao-proposal-hook-counter/src/state.rs new file mode 100644 index 000000000..4f22cb0e7 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/src/state.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::cw_serde; +use cw_storage_plus::Item; + +#[cw_serde] +pub struct Config { + pub should_error: bool, +} +pub const CONFIG: Item = Item::new("config"); +pub const VOTE_COUNTER: Item = Item::new("vote_counter"); +pub const PROPOSAL_COUNTER: Item = Item::new("proposal_counter"); +pub const STATUS_CHANGED_COUNTER: Item = Item::new("stauts_changed_counter"); diff --git a/test-contracts/dao-proposal-hook-counter/src/tests.rs b/test-contracts/dao-proposal-hook-counter/src/tests.rs new file mode 100644 index 000000000..cb3483a81 --- /dev/null +++ b/test-contracts/dao-proposal-hook-counter/src/tests.rs @@ -0,0 +1,473 @@ +use cosmwasm_std::{to_binary, Addr, Empty, Uint128}; +use cw20::Cw20Coin; +use cw_hooks::HooksResponse; +use cw_multi_test::{App, Contract, ContractWrapper, Executor}; +use dao_interface::state::ProposalModule; +use dao_interface::state::{Admin, ModuleInstantiateInfo}; + +use dao_voting::{ + pre_propose::PreProposeInfo, + threshold::{PercentageThreshold, Threshold}, + voting::Vote, +}; + +use crate::msg::{CountResponse, InstantiateMsg, QueryMsg}; +use dao_proposal_single::state::Config; +use dao_voting::proposal::SingleChoiceProposeMsg as ProposeMsg; + +const CREATOR_ADDR: &str = "creator"; + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn single_govmod_contract() -> Box> { + let contract = ContractWrapper::new( + dao_proposal_single::contract::execute, + dao_proposal_single::contract::instantiate, + dao_proposal_single::contract::query, + ) + .with_reply(dao_proposal_single::contract::reply); + Box::new(contract) +} + +fn cw20_balances_voting() -> Box> { + let contract = ContractWrapper::new( + dao_voting_cw20_balance::contract::execute, + dao_voting_cw20_balance::contract::instantiate, + dao_voting_cw20_balance::contract::query, + ) + .with_reply(dao_voting_cw20_balance::contract::reply); + Box::new(contract) +} + +fn cw_gov_contract() -> Box> { + let contract = ContractWrapper::new( + dao_dao_core::contract::execute, + dao_dao_core::contract::instantiate, + dao_dao_core::contract::query, + ) + .with_reply(dao_dao_core::contract::reply); + Box::new(contract) +} + +fn counters_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn instantiate_governance( + app: &mut App, + code_id: u64, + msg: dao_interface::msg::InstantiateMsg, +) -> Addr { + app.instantiate_contract( + code_id, + Addr::unchecked(CREATOR_ADDR), + &msg, + &[], + "cw-governance", + None, + ) + .unwrap() +} + +fn instantiate_with_default_governance( + app: &mut App, + code_id: u64, + msg: dao_proposal_single::msg::InstantiateMsg, + initial_balances: Option>, +) -> Addr { + let cw20_id = app.store_code(cw20_contract()); + let governance_id = app.store_code(cw_gov_contract()); + let votemod_id = app.store_code(cw20_balances_voting()); + + let initial_balances = initial_balances.unwrap_or_else(|| { + vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::new(100_000_000), + }] + }); + + let governance_instantiate = dao_interface::msg::InstantiateMsg { + dao_uri: None, + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that builds DAOs".to_string(), + image_url: None, + automatically_add_cw20s: true, + automatically_add_cw721s: true, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: votemod_id, + msg: to_binary(&dao_voting_cw20_balance::msg::InstantiateMsg { + token_info: dao_voting_cw20_balance::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO governance token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances, + marketing: None, + }, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO voting module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id, + msg: to_binary(&msg).unwrap(), + admin: Some(Admin::CoreModule {}), + label: "DAO DAO governance module".to_string(), + }], + initial_items: None, + }; + + instantiate_governance(app, governance_id, governance_instantiate) +} + +#[test] +fn test_counters() { + let mut app = App::default(); + let govmod_id = app.store_code(single_govmod_contract()); + let counters_id = app.store_code(counters_contract()); + + let threshold = Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }; + let max_voting_period = cw_utils::Duration::Height(6); + let instantiate = dao_proposal_single::msg::InstantiateMsg { + threshold, + max_voting_period, + min_voting_period: None, + only_members_execute: false, + allow_revoting: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + close_proposal_on_execution_failure: true, + }; + + let governance_addr = + instantiate_with_default_governance(&mut app, govmod_id, instantiate, None); + let governance_modules: Vec = app + .wrap() + .query_wasm_smart( + governance_addr, + &dao_interface::msg::QueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(governance_modules.len(), 1); + let govmod_single = governance_modules.into_iter().next().unwrap().address; + + let govmod_config: Config = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::Config {}, + ) + .unwrap(); + let dao = govmod_config.dao; + + let counters: Addr = app + .instantiate_contract( + counters_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { + should_error: false, + }, + &[], + "counters", + None, + ) + .unwrap(); + let failing_counters: Addr = app + .instantiate_contract( + counters_id, + Addr::unchecked(CREATOR_ADDR), + &InstantiateMsg { should_error: true }, + &[], + "failing counters", + None, + ) + .unwrap(); + + // Register both hooks + app.execute_contract( + dao.clone(), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::AddProposalHook { + address: counters.to_string(), + }, + &[], + ) + .unwrap(); + app.execute_contract( + dao.clone(), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::AddVoteHook { + address: counters.to_string(), + }, + &[], + ) + .unwrap(); + + // Query both hooks + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 1); + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::VoteHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 1); + + // Query proposal counter, expect 0 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters.clone(), &QueryMsg::ProposalCounter {}) + .unwrap(); + assert_eq!(resp.count, 0); + + // Create a new proposal. + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::Propose(ProposeMsg { + title: "A simple text proposal".to_string(), + description: "This is a simple text proposal".to_string(), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap(); + + // Query proposal counter, expect 1 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters.clone(), &QueryMsg::ProposalCounter {}) + .unwrap(); + assert_eq!(resp.count, 1); + + // Query vote counter, expect 0 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters.clone(), &QueryMsg::VoteCounter {}) + .unwrap(); + assert_eq!(resp.count, 0); + + // Query status changed counter, expect 0 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters.clone(), &QueryMsg::StatusChangedCounter {}) + .unwrap(); + assert_eq!(resp.count, 0); + + // Vote + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::Vote { + proposal_id: 1, + vote: Vote::Yes, + rationale: None, + }, + &[], + ) + .unwrap(); + + // Query vote counter, expect 1 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters.clone(), &QueryMsg::VoteCounter {}) + .unwrap(); + assert_eq!(resp.count, 1); + + // Query status changed counter, expect 1 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters.clone(), &QueryMsg::StatusChangedCounter {}) + .unwrap(); + assert_eq!(resp.count, 1); + + // Register the failing hooks + app.execute_contract( + dao.clone(), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::AddProposalHook { + address: failing_counters.to_string(), + }, + &[], + ) + .unwrap(); + app.execute_contract( + dao.clone(), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::AddVoteHook { + address: failing_counters.to_string(), + }, + &[], + ) + .unwrap(); + + // Expect 2 for each hook + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 2); + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::VoteHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 2); + + // Create a new proposal. + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::Propose(ProposeMsg { + title: "A simple text proposal 2nd".to_string(), + description: "This is a simple text proposal 2nd".to_string(), + msgs: vec![], + proposer: None, + }), + &[], + ) + .unwrap(); + + // The success counters should still work + // Query proposal counter, expect 2 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters.clone(), &QueryMsg::ProposalCounter {}) + .unwrap(); + assert_eq!(resp.count, 2); + + // The contract should of removed the failing counters + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 1); + + // To verify it removed the right one, lets try and remove failing counters + // will fail as it does not exist. + let _err = app + .execute_contract( + dao.clone(), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::RemoveProposalHook { + address: failing_counters.to_string(), + }, + &[], + ) + .unwrap_err(); + + // It should still have the vote hook as that has not technically failed yet + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::VoteHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 2); + + // Vote on the new proposal to fail the other hook + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::Vote { + rationale: None, + proposal_id: 2, + vote: Vote::Yes, + }, + &[], + ) + .unwrap(); + + // The success counters should still work + // Query vote counter, expect 2 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters.clone(), &QueryMsg::VoteCounter {}) + .unwrap(); + assert_eq!(resp.count, 2); + // Query status changed counter, expect 2 + let resp: CountResponse = app + .wrap() + .query_wasm_smart(counters, &QueryMsg::StatusChangedCounter {}) + .unwrap(); + assert_eq!(resp.count, 2); + + // The contract should of removed the failing counters + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::VoteHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 1); + + // To verify it removed the right one, lets try and remove failing counters + // will fail as it does not exist. + let _err = app + .execute_contract( + dao, + govmod_single.clone(), + &dao_proposal_single::msg::ExecuteMsg::RemoveVoteHook { + address: failing_counters.to_string(), + }, + &[], + ) + .unwrap_err(); + + // Verify only one hook remains for each + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 1); + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart( + govmod_single, + &dao_proposal_single::msg::QueryMsg::VoteHooks {}, + ) + .unwrap(); + assert_eq!(hooks.hooks.len(), 1); +} diff --git a/test-contracts/dao-proposal-sudo/.cargo/config b/test-contracts/dao-proposal-sudo/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/test-contracts/dao-proposal-sudo/.gitignore b/test-contracts/dao-proposal-sudo/.gitignore new file mode 100644 index 000000000..dfdaaa6bc --- /dev/null +++ b/test-contracts/dao-proposal-sudo/.gitignore @@ -0,0 +1,15 @@ +# Build results +/target + +# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327) +.cargo-ok + +# Text file backups +**/*.rs.bk + +# macOS +.DS_Store + +# IDEs +*.iml +.idea diff --git a/test-contracts/dao-proposal-sudo/Cargo.toml b/test-contracts/dao-proposal-sudo/Cargo.toml new file mode 100644 index 000000000..9120fdf8b --- /dev/null +++ b/test-contracts/dao-proposal-sudo/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "dao-proposal-sudo" +authors = ["ekez "] +description = "A proposal module that allows direct execution without voting." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +thiserror = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/test-contracts/dao-proposal-sudo/README b/test-contracts/dao-proposal-sudo/README new file mode 100644 index 000000000..43cfd552d --- /dev/null +++ b/test-contracts/dao-proposal-sudo/README @@ -0,0 +1,5 @@ +# dao-proposal-sudo + +A governance module for the cw-governance contract. Instantiated with +a root address. The root address may indiscriminately cause the module +to execute messages on the DAO. No other user may do this. diff --git a/test-contracts/dao-proposal-sudo/examples/schema.rs b/test-contracts/dao-proposal-sudo/examples/schema.rs new file mode 100644 index 000000000..ff6a30563 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/examples/schema.rs @@ -0,0 +1,25 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, schema_for}; +use cosmwasm_std::Addr; +use dao_interface::voting::InfoResponse; +use dao_proposal_sudo::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); + + export_schema(&schema_for!(InfoResponse), &out_dir); + + // Auto TS code generation expects the query return type as QueryNameResponse + // Here we map query resonses to the correct name + export_schema_with_title(&schema_for!(Addr), &out_dir, "DaoResponse"); + export_schema_with_title(&schema_for!(Addr), &out_dir, "AdminResponse"); +} diff --git a/test-contracts/dao-proposal-sudo/schema/admin_response.json b/test-contracts/dao-proposal-sudo/schema/admin_response.json new file mode 100644 index 000000000..920968701 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/schema/admin_response.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AdminResponse", + "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" +} diff --git a/test-contracts/dao-proposal-sudo/schema/dao_response.json b/test-contracts/dao-proposal-sudo/schema/dao_response.json new file mode 100644 index 000000000..9518ba3b5 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/schema/dao_response.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DaoResponse", + "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" +} diff --git a/test-contracts/dao-proposal-sudo/schema/execute_msg.json b/test-contracts/dao-proposal-sudo/schema/execute_msg.json new file mode 100644 index 000000000..302a22adc --- /dev/null +++ b/test-contracts/dao-proposal-sudo/schema/execute_msg.json @@ -0,0 +1,487 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "msgs" + ], + "properties": { + "msgs": { + "type": "array", + "items": { + "$ref": "#/definitions/CosmosMsg_for_Empty" + } + } + } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "BankMsg": { + "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto", + "oneOf": [ + { + "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "to_address" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "to_address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec", + "type": "string" + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "CosmosMsg_for_Empty": { + "oneOf": [ + { + "type": "object", + "required": [ + "bank" + ], + "properties": { + "bank": { + "$ref": "#/definitions/BankMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "custom" + ], + "properties": { + "custom": { + "$ref": "#/definitions/Empty" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staking" + ], + "properties": { + "staking": { + "$ref": "#/definitions/StakingMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "$ref": "#/definitions/DistributionMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "wasm" + ], + "properties": { + "wasm": { + "$ref": "#/definitions/WasmMsg" + } + }, + "additionalProperties": false + } + ] + }, + "DistributionMsg": { + "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "set_withdraw_address" + ], + "properties": { + "set_withdraw_address": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "description": "The `withdraw_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "withdraw_delegator_reward" + ], + "properties": { + "withdraw_delegator_reward": { + "type": "object", + "required": [ + "validator" + ], + "properties": { + "validator": { + "description": "The `validator_address`", + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Empty": { + "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" + }, + "StakingMsg": { + "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto", + "oneOf": [ + { + "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "amount", + "validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "redelegate" + ], + "properties": { + "redelegate": { + "type": "object", + "required": [ + "amount", + "dst_validator", + "src_validator" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Coin" + }, + "dst_validator": { + "type": "string" + }, + "src_validator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "WasmMsg": { + "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto", + "oneOf": [ + { + "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "execute" + ], + "properties": { + "execute": { + "type": "object", + "required": [ + "contract_addr", + "funds", + "msg" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "msg": { + "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.16.0-alpha1/x/wasm/internal/types/tx.proto#L47-L61). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "instantiate" + ], + "properties": { + "instantiate": { + "type": "object", + "required": [ + "code_id", + "funds", + "label", + "msg" + ], + "properties": { + "admin": { + "type": [ + "string", + "null" + ] + }, + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "funds": { + "type": "array", + "items": { + "$ref": "#/definitions/Coin" + } + }, + "label": { + "description": "A human-readbale label for the contract", + "type": "string" + }, + "msg": { + "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.", + "type": "object", + "required": [ + "migrate" + ], + "properties": { + "migrate": { + "type": "object", + "required": [ + "contract_addr", + "msg", + "new_code_id" + ], + "properties": { + "contract_addr": { + "type": "string" + }, + "msg": { + "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code", + "allOf": [ + { + "$ref": "#/definitions/Binary" + } + ] + }, + "new_code_id": { + "description": "the code_id of the new logic to place in the given contract", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "update_admin" + ], + "properties": { + "update_admin": { + "type": "object", + "required": [ + "admin", + "contract_addr" + ], + "properties": { + "admin": { + "type": "string" + }, + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.", + "type": "object", + "required": [ + "clear_admin" + ], + "properties": { + "clear_admin": { + "type": "object", + "required": [ + "contract_addr" + ], + "properties": { + "contract_addr": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + } + } +} diff --git a/test-contracts/dao-proposal-sudo/schema/info_response.json b/test-contracts/dao-proposal-sudo/schema/info_response.json new file mode 100644 index 000000000..a0516764e --- /dev/null +++ b/test-contracts/dao-proposal-sudo/schema/info_response.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "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" + } + } + } + } +} diff --git a/test-contracts/dao-proposal-sudo/schema/instantiate_msg.json b/test-contracts/dao-proposal-sudo/schema/instantiate_msg.json new file mode 100644 index 000000000..8e3416dc4 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/schema/instantiate_msg.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "root" + ], + "properties": { + "root": { + "type": "string" + } + } +} diff --git a/test-contracts/dao-proposal-sudo/schema/query_msg.json b/test-contracts/dao-proposal-sudo/schema/query_msg.json new file mode 100644 index 000000000..5262b4290 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/schema/query_msg.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "admin" + ], + "properties": { + "admin": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "dao" + ], + "properties": { + "dao": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object" + } + }, + "additionalProperties": false + } + ] +} diff --git a/test-contracts/dao-proposal-sudo/src/contract.rs b/test-contracts/dao-proposal-sudo/src/contract.rs new file mode 100644 index 000000000..82df228e3 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/src/contract.rs @@ -0,0 +1,91 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Env, MessageInfo, Response, StdResult, + WasmMsg, +}; +use cw2::set_contract_version; + +use crate::{ + error::ContractError, + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + state::{DAO, ROOT}, +}; + +const CONTRACT_NAME: &str = "crates.io:cw-govmod-sudo"; +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)?; + + let root = deps.api.addr_validate(&msg.root)?; + ROOT.save(deps.storage, &root)?; + DAO.save(deps.storage, &info.sender)?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("root", root)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Execute { msgs } => execute_execute(deps.as_ref(), info.sender, msgs), + } +} + +pub fn execute_execute( + deps: Deps, + sender: Addr, + msgs: Vec, +) -> Result { + let root = ROOT.load(deps.storage)?; + let dao = DAO.load(deps.storage)?; + + if sender != root { + return Err(ContractError::Unauthorized {}); + } + + let msg = WasmMsg::Execute { + contract_addr: dao.to_string(), + msg: to_binary(&dao_interface::msg::ExecuteMsg::ExecuteProposalHook { msgs })?, + funds: vec![], + }; + + Ok(Response::default() + .add_attribute("action", "execute_execute") + .add_message(msg)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Admin {} => query_admin(deps), + QueryMsg::Dao {} => query_dao(deps), + QueryMsg::Info {} => query_info(deps), + } +} + +pub fn query_admin(deps: Deps) -> StdResult { + to_binary(&ROOT.load(deps.storage)?) +} + +pub fn query_dao(deps: Deps) -> StdResult { + to_binary(&DAO.load(deps.storage)?) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} diff --git a/test-contracts/dao-proposal-sudo/src/error.rs b/test-contracts/dao-proposal-sudo/src/error.rs new file mode 100644 index 000000000..dc19f1033 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/src/error.rs @@ -0,0 +1,11 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, +} diff --git a/test-contracts/dao-proposal-sudo/src/lib.rs b/test-contracts/dao-proposal-sudo/src/lib.rs new file mode 100644 index 000000000..dfedc9dc6 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/src/lib.rs @@ -0,0 +1,6 @@ +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/test-contracts/dao-proposal-sudo/src/msg.rs b/test-contracts/dao-proposal-sudo/src/msg.rs new file mode 100644 index 000000000..142148400 --- /dev/null +++ b/test-contracts/dao-proposal-sudo/src/msg.rs @@ -0,0 +1,23 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::CosmosMsg; + +#[cw_serde] +pub struct InstantiateMsg { + pub root: String, +} + +#[cw_serde] +pub enum ExecuteMsg { + Execute { msgs: Vec }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(cosmwasm_std::Addr)] + Admin {}, + #[returns(cosmwasm_std::Addr)] + Dao {}, + #[returns(dao_interface::voting::InfoResponse)] + Info {}, +} diff --git a/test-contracts/dao-proposal-sudo/src/state.rs b/test-contracts/dao-proposal-sudo/src/state.rs new file mode 100644 index 000000000..6003bc7fc --- /dev/null +++ b/test-contracts/dao-proposal-sudo/src/state.rs @@ -0,0 +1,5 @@ +use cosmwasm_std::Addr; +use cw_storage_plus::Item; + +pub const ROOT: Item = Item::new("root"); +pub const DAO: Item = Item::new("dao"); diff --git a/test-contracts/dao-voting-cw20-balance/.cargo/config b/test-contracts/dao-voting-cw20-balance/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/test-contracts/dao-voting-cw20-balance/.gitignore b/test-contracts/dao-voting-cw20-balance/.gitignore new file mode 100644 index 000000000..dfdaaa6bc --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/.gitignore @@ -0,0 +1,15 @@ +# Build results +/target + +# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327) +.cargo-ok + +# Text file backups +**/*.rs.bk + +# macOS +.DS_Store + +# IDEs +*.iml +.idea diff --git a/test-contracts/dao-voting-cw20-balance/Cargo.toml b/test-contracts/dao-voting-cw20-balance/Cargo.toml new file mode 100644 index 000000000..f126b6840 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "dao-voting-cw20-balance" +authors = ["ekez "] +description = "A DAO DAO test contract." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw-utils = { workspace = true } +thiserror = { workspace = true } +dao-dao-macros = { workspace = true } +dao-interface = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/test-contracts/dao-voting-cw20-balance/README.md b/test-contracts/dao-voting-cw20-balance/README.md new file mode 100644 index 000000000..b1e35889e --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/README.md @@ -0,0 +1,4 @@ +# cw balance voting + +A simple voting power module which determines voting power based on +the token balance of specific addresses. diff --git a/test-contracts/dao-voting-cw20-balance/examples/schema.rs b/test-contracts/dao-voting-cw20-balance/examples/schema.rs new file mode 100644 index 000000000..bbc4e96c2 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/examples/schema.rs @@ -0,0 +1,24 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; + +use dao_interface::voting::{ + InfoResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; +use dao_voting_cw20_balance::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); + + export_schema(&schema_for!(InfoResponse), &out_dir); + export_schema(&schema_for!(TotalPowerAtHeightResponse), &out_dir); + export_schema(&schema_for!(VotingPowerAtHeightResponse), &out_dir); +} diff --git a/test-contracts/dao-voting-cw20-balance/schema/execute_msg.json b/test-contracts/dao-voting-cw20-balance/schema/execute_msg.json new file mode 100644 index 000000000..b3d18b476 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/schema/execute_msg.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "type": "string", + "enum": [] +} diff --git a/test-contracts/dao-voting-cw20-balance/schema/info_response.json b/test-contracts/dao-voting-cw20-balance/schema/info_response.json new file mode 100644 index 000000000..a0516764e --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/schema/info_response.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "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" + } + } + } + } +} diff --git a/test-contracts/dao-voting-cw20-balance/schema/instantiate_msg.json b/test-contracts/dao-voting-cw20-balance/schema/instantiate_msg.json new file mode 100644 index 000000000..592202803 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/schema/instantiate_msg.json @@ -0,0 +1,214 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "token_info" + ], + "properties": { + "token_info": { + "$ref": "#/definitions/TokenInfo" + } + }, + "definitions": { + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec", + "type": "string" + }, + "Cw20Coin": { + "type": "object", + "required": [ + "address", + "amount" + ], + "properties": { + "address": { + "type": "string" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + } + }, + "EmbeddedLogo": { + "description": "This is used to store the logo on the blockchain in an accepted format. Enforce maximum size of 5KB on all variants.", + "oneOf": [ + { + "description": "Store the Logo as an SVG file. The content must conform to the spec at https://en.wikipedia.org/wiki/Scalable_Vector_Graphics (The contract should do some light-weight sanity-check validation)", + "type": "object", + "required": [ + "svg" + ], + "properties": { + "svg": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + }, + { + "description": "Store the Logo as a PNG file. This will likely only support up to 64x64 or so within the 5KB limit.", + "type": "object", + "required": [ + "png" + ], + "properties": { + "png": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + } + ] + }, + "InstantiateMarketingInfo": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/Logo" + }, + { + "type": "null" + } + ] + }, + "marketing": { + "type": [ + "string", + "null" + ] + }, + "project": { + "type": [ + "string", + "null" + ] + } + } + }, + "Logo": { + "description": "This is used for uploading logo data, or setting it in InstantiateData", + "oneOf": [ + { + "description": "A reference to an externally hosted logo. Must be a valid HTTP or HTTPS URL.", + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "Logo content stored on the blockchain. Enforce maximum size of 5KB on all variants", + "type": "object", + "required": [ + "embedded" + ], + "properties": { + "embedded": { + "$ref": "#/definitions/EmbeddedLogo" + } + }, + "additionalProperties": false + } + ] + }, + "TokenInfo": { + "oneOf": [ + { + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "new" + ], + "properties": { + "new": { + "type": "object", + "required": [ + "code_id", + "decimals", + "initial_balances", + "label", + "name", + "symbol" + ], + "properties": { + "code_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "decimals": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "initial_balances": { + "type": "array", + "items": { + "$ref": "#/definitions/Cw20Coin" + } + }, + "label": { + "type": "string" + }, + "marketing": { + "anyOf": [ + { + "$ref": "#/definitions/InstantiateMarketingInfo" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/test-contracts/dao-voting-cw20-balance/schema/query_msg.json b/test-contracts/dao-voting-cw20-balance/schema/query_msg.json new file mode 100644 index 000000000..fdde1e920 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/schema/query_msg.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "token_contract" + ], + "properties": { + "token_contract": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "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 + }, + { + "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 + }, + { + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object" + } + }, + "additionalProperties": false + } + ] +} diff --git a/test-contracts/dao-voting-cw20-balance/schema/total_power_at_height_response.json b/test-contracts/dao-voting-cw20-balance/schema/total_power_at_height_response.json new file mode 100644 index 000000000..8018462bf --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/schema/total_power_at_height_response.json @@ -0,0 +1,25 @@ +{ + "$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" + } + }, + "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/test-contracts/dao-voting-cw20-balance/schema/voting_power_at_height_response.json b/test-contracts/dao-voting-cw20-balance/schema/voting_power_at_height_response.json new file mode 100644 index 000000000..15e986bf8 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/schema/voting_power_at_height_response.json @@ -0,0 +1,25 @@ +{ + "$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" + } + }, + "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/test-contracts/dao-voting-cw20-balance/src/contract.rs b/test-contracts/dao-voting-cw20-balance/src/contract.rs new file mode 100644 index 000000000..9c533568c --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/src/contract.rs @@ -0,0 +1,166 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Reply, Response, StdResult, SubMsg, + Uint128, WasmMsg, +}; +use cw2::set_contract_version; +use cw_utils::parse_reply_instantiate_data; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, TokenInfo}; +use crate::state::{DAO, TOKEN}; + +const CONTRACT_NAME: &str = "crates.io:cw20-balance-voting"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const INSTANTIATE_TOKEN_REPLY_ID: u64 = 0; + +#[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)?; + + DAO.save(deps.storage, &info.sender)?; + + match msg.token_info { + TokenInfo::Existing { address } => { + let address = deps.api.addr_validate(&address)?; + TOKEN.save(deps.storage, &address)?; + Ok(Response::default() + .add_attribute("action", "instantiate") + .add_attribute("token", "existing_token") + .add_attribute("token_address", address)) + } + TokenInfo::New { + code_id, + label, + name, + symbol, + decimals, + initial_balances, + marketing, + } => { + let initial_supply = initial_balances + .iter() + .fold(Uint128::zero(), |p, n| p + n.amount); + if initial_supply.is_zero() { + return Err(ContractError::InitialBalancesError {}); + } + + let msg = WasmMsg::Instantiate { + admin: Some(info.sender.to_string()), + code_id, + msg: to_binary(&cw20_base::msg::InstantiateMsg { + name, + symbol, + decimals, + initial_balances, + mint: Some(cw20::MinterResponse { + minter: info.sender.to_string(), + cap: None, + }), + marketing, + })?, + funds: vec![], + label, + }; + let msg = SubMsg::reply_on_success(msg, INSTANTIATE_TOKEN_REPLY_ID); + + Ok(Response::default() + .add_attribute("action", "instantiate") + .add_attribute("token", "new_token") + .add_submessage(msg)) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg {} +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::TokenContract {} => query_token_contract(deps), + QueryMsg::VotingPowerAtHeight { address, height: _ } => { + query_voting_power_at_height(deps, env, address) + } + QueryMsg::TotalPowerAtHeight { height: _ } => query_total_power_at_height(deps, env), + QueryMsg::Info {} => query_info(deps), + QueryMsg::Dao {} => query_dao(deps), + } +} + +pub fn query_dao(deps: Deps) -> StdResult { + let dao = DAO.load(deps.storage)?; + to_binary(&dao) +} + +pub fn query_token_contract(deps: Deps) -> StdResult { + let token = TOKEN.load(deps.storage)?; + to_binary(&token) +} + +pub fn query_voting_power_at_height(deps: Deps, env: Env, address: String) -> StdResult { + let token = TOKEN.load(deps.storage)?; + let address = deps.api.addr_validate(&address)?; + let balance: cw20::BalanceResponse = deps.querier.query_wasm_smart( + token, + &cw20::Cw20QueryMsg::Balance { + address: address.to_string(), + }, + )?; + to_binary(&dao_interface::voting::VotingPowerAtHeightResponse { + power: balance.balance, + height: env.block.height, + }) +} + +pub fn query_total_power_at_height(deps: Deps, env: Env) -> StdResult { + let token = TOKEN.load(deps.storage)?; + let info: cw20::TokenInfoResponse = deps + .querier + .query_wasm_smart(token, &cw20::Cw20QueryMsg::TokenInfo {})?; + to_binary(&dao_interface::voting::TotalPowerAtHeightResponse { + power: info.total_supply, + height: env.block.height, + }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_binary(&dao_interface::voting::InfoResponse { info }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + INSTANTIATE_TOKEN_REPLY_ID => { + let res = parse_reply_instantiate_data(msg); + match res { + Ok(res) => { + let token = TOKEN.may_load(deps.storage)?; + if token.is_some() { + return Err(ContractError::DuplicateToken {}); + } + let token = deps.api.addr_validate(&res.contract_address)?; + TOKEN.save(deps.storage, &token)?; + Ok(Response::default().add_attribute("token_address", token)) + } + Err(_) => Err(ContractError::TokenInstantiateError {}), + } + } + _ => Err(ContractError::UnknownReplyId { id: msg.id }), + } +} diff --git a/test-contracts/dao-voting-cw20-balance/src/error.rs b/test-contracts/dao-voting-cw20-balance/src/error.rs new file mode 100644 index 000000000..dfa73357e --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/src/error.rs @@ -0,0 +1,23 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Initial governance token balances must not be empty")] + InitialBalancesError {}, + + #[error("Can not change the contract's token after it has been set")] + DuplicateToken {}, + + #[error("Error instantiating token")] + TokenInstantiateError {}, + + #[error("Got a submessage reply with unknown id: {id}")] + UnknownReplyId { id: u64 }, +} diff --git a/test-contracts/dao-voting-cw20-balance/src/lib.rs b/test-contracts/dao-voting-cw20-balance/src/lib.rs new file mode 100644 index 000000000..8e58a2f17 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/test-contracts/dao-voting-cw20-balance/src/msg.rs b/test-contracts/dao-voting-cw20-balance/src/msg.rs new file mode 100644 index 000000000..6a0a5cae3 --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/src/msg.rs @@ -0,0 +1,37 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +use cw20::Cw20Coin; +use cw20_base::msg::InstantiateMarketingInfo; + +use dao_dao_macros::{token_query, voting_module_query}; + +#[cw_serde] +pub enum TokenInfo { + Existing { + address: String, + }, + New { + code_id: u64, + label: String, + + name: String, + symbol: String, + decimals: u8, + initial_balances: Vec, + marketing: Option, + }, +} + +#[cw_serde] +pub struct InstantiateMsg { + pub token_info: TokenInfo, +} + +#[cw_serde] +pub enum ExecuteMsg {} + +#[token_query] +#[voting_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg {} diff --git a/test-contracts/dao-voting-cw20-balance/src/state.rs b/test-contracts/dao-voting-cw20-balance/src/state.rs new file mode 100644 index 000000000..65ea7468a --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/src/state.rs @@ -0,0 +1,5 @@ +use cosmwasm_std::Addr; +use cw_storage_plus::Item; + +pub const DAO: Item = Item::new("dao"); +pub const TOKEN: Item = Item::new("token"); diff --git a/test-contracts/dao-voting-cw20-balance/src/tests.rs b/test-contracts/dao-voting-cw20-balance/src/tests.rs new file mode 100644 index 000000000..4560fd5ea --- /dev/null +++ b/test-contracts/dao-voting-cw20-balance/src/tests.rs @@ -0,0 +1,386 @@ +use cosmwasm_std::{Addr, Empty, Uint128}; +use cw2::ContractVersion; +use cw20::{Cw20Coin, MinterResponse, TokenInfoResponse}; +use cw_multi_test::{App, Contract, ContractWrapper, Executor}; +use dao_interface::voting::{InfoResponse, VotingPowerAtHeightResponse}; + +use crate::msg::{InstantiateMsg, QueryMsg}; + +const DAO_ADDR: &str = "dao"; +const CREATOR_ADDR: &str = "creator"; + +fn cw20_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) +} + +fn balance_voting_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply); + Box::new(contract) +} + +fn instantiate_voting(app: &mut App, voting_id: u64, msg: InstantiateMsg) -> Addr { + app.instantiate_contract( + voting_id, + Addr::unchecked(DAO_ADDR), + &msg, + &[], + "voting module", + None, + ) + .unwrap() +} + +#[test] +#[should_panic(expected = "Initial governance token balances must not be empty")] +fn test_instantiate_zero_supply() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(balance_voting_contract()); + instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::zero(), + }], + marketing: None, + }, + }, + ); +} + +#[test] +#[should_panic(expected = "Initial governance token balances must not be empty")] +fn test_instantiate_no_balances() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(balance_voting_contract()); + instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![], + marketing: None, + }, + }, + ); +} + +#[test] +fn test_contract_info() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(balance_voting_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + }, + }, + ); + + let info: InfoResponse = app + .wrap() + .query_wasm_smart(voting_addr, &QueryMsg::Info {}) + .unwrap(); + assert_eq!( + info, + InfoResponse { + info: ContractVersion { + contract: "crates.io:cw20-balance-voting".to_string(), + version: env!("CARGO_PKG_VERSION").to_string() + } + } + ) +} + +#[test] +fn test_new_cw20() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(balance_voting_contract()); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::New { + code_id: cw20_id, + label: "DAO DAO voting".to_string(), + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + marketing: None, + }, + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + + let token_info: TokenInfoResponse = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::TokenInfo {}) + .unwrap(); + assert_eq!( + token_info, + TokenInfoResponse { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + total_supply: Uint128::from(2u64) + } + ); + + let minter_info: Option = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::Minter {}) + .unwrap(); + assert_eq!( + minter_info, + Some(MinterResponse { + minter: DAO_ADDR.to_string(), + cap: None, + }) + ); + + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::from(2u64), + height: app.block_info().height, + } + ); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_addr, + &cw20::Cw20ExecuteMsg::Transfer { + recipient: DAO_ADDR.to_string(), + amount: Uint128::from(1u64), + }, + &[], + ) + .unwrap(); + + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::from(1u64), + height: app.block_info().height, + } + ); + + let dao_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr, + &QueryMsg::VotingPowerAtHeight { + address: DAO_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + dao_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::from(1u64), + height: app.block_info().height, + } + ); +} + +#[test] +fn test_existing_cw20() { + let mut app = App::default(); + let cw20_id = app.store_code(cw20_contract()); + let voting_id = app.store_code(balance_voting_contract()); + + let token_addr = app + .instantiate_contract( + cw20_id, + Addr::unchecked(CREATOR_ADDR), + &cw20_base::msg::InstantiateMsg { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: CREATOR_ADDR.to_string(), + amount: Uint128::from(2u64), + }], + mint: None, + marketing: None, + }, + &[], + "voting token", + None, + ) + .unwrap(); + + let voting_addr = instantiate_voting( + &mut app, + voting_id, + InstantiateMsg { + token_info: crate::msg::TokenInfo::Existing { + address: token_addr.to_string(), + }, + }, + ); + + let token_addr: Addr = app + .wrap() + .query_wasm_smart(voting_addr.clone(), &QueryMsg::TokenContract {}) + .unwrap(); + + let token_info: TokenInfoResponse = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::TokenInfo {}) + .unwrap(); + assert_eq!( + token_info, + TokenInfoResponse { + name: "DAO DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 3, + total_supply: Uint128::from(2u64) + } + ); + + let minter_info: Option = app + .wrap() + .query_wasm_smart(token_addr.clone(), &cw20::Cw20QueryMsg::Minter {}) + .unwrap(); + assert!(minter_info.is_none()); + + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::from(2u64), + height: app.block_info().height, + } + ); + + app.execute_contract( + Addr::unchecked(CREATOR_ADDR), + token_addr, + &cw20::Cw20ExecuteMsg::Transfer { + recipient: DAO_ADDR.to_string(), + amount: Uint128::from(1u64), + }, + &[], + ) + .unwrap(); + + let creator_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr.clone(), + &QueryMsg::VotingPowerAtHeight { + address: CREATOR_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + creator_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::from(1u64), + height: app.block_info().height, + } + ); + + let dao_voting_power: VotingPowerAtHeightResponse = app + .wrap() + .query_wasm_smart( + voting_addr, + &QueryMsg::VotingPowerAtHeight { + address: DAO_ADDR.to_string(), + height: None, + }, + ) + .unwrap(); + + assert_eq!( + dao_voting_power, + VotingPowerAtHeightResponse { + power: Uint128::from(1u64), + height: app.block_info().height, + } + ); +}