diff --git a/Cargo.lock b/Cargo.lock index b1e7d67d1..51663573b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,273 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "abstract-cw-multi-test" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c77f8d4bac08f74fbc4fce8943cb2d35e742682b6cae8cb65555d6cd3830feb" +dependencies = [ + "anyhow", + "bech32 0.11.0", + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw20-ics20", + "derivative", + "hex", + "itertools 0.12.1", + "log", + "prost 0.12.3", + "schemars", + "serde", + "serde_json", + "sha2 0.10.8", + "thiserror", +] + +[[package]] +name = "abstract-cw-plus-interface" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7441425a805439500492977107d154af02b1702aa044945775c245d0b3469968" +dependencies = [ + "abstract-cw1", + "abstract-cw1-subkeys", + "abstract-cw1-whitelist", + "abstract-cw20-base", + "abstract-cw20-ics20", + "abstract-cw3-fixed-multisig", + "abstract-cw3-flex-multisig", + "abstract-cw4-group", + "abstract-cw4-stake", + "cosmwasm-std", + "cw-orch", +] + +[[package]] +name = "abstract-cw1" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0895c076ab6a5165133a453f983ec9ccc9b6c41de256b6eb74e523eb555b3ebb" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "abstract-cw1-subkeys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab08cdd6008afa38a52427943bf4aef9541bde78cc9c14849a53ad2608a1161e" +dependencies = [ + "abstract-cw1", + "abstract-cw1-whitelist", + "abstract-cw2", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw1-whitelist" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171a0b5b3694627cf0fa554500d72431169d4013fffd14650d2b7d660230a205" +dependencies = [ + "abstract-cw1", + "abstract-cw2", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw2" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945af4c176b4539be2a74c06aa166287ba964ab58aec98c644addd812431f141" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw20" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d5e4b8084c3a2b3e42502e6c4fe3ed985dc72e86eb612bcc527f4a0443fa42" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-utils 1.0.3", + "schemars", + "serde", +] + +[[package]] +name = "abstract-cw20-base" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d300dec7d602e00841c5ab6fe598d4d290bab32e489c6885c607633c4f3fe67" +dependencies = [ + "abstract-cw2", + "abstract-cw20", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw20-ics20" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027678ddb0e62b4aba5f0167d2b0a3ec0182e1e32c47759be7e30b56775598ee" +dependencies = [ + "abstract-cw2", + "abstract-cw20", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw3" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c080cc760333d1d3477857aeac19aa7e6e661f1e58d04a7a78212913d49bf517" +dependencies = [ + "abstract-cw20", + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw3-fixed-multisig" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1882e05bef33bd1c6b25e735eda8a23332a78c4df0b24a18ca56a8ca8ed6f222" +dependencies = [ + "abstract-cw2", + "abstract-cw3", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw3-flex-multisig" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92379f3e7c467f081312d6953eb8d300456efa352c9f7c5ef095ad99083d92db" +dependencies = [ + "abstract-cw2", + "abstract-cw20", + "abstract-cw3", + "abstract-cw3-fixed-multisig", + "abstract-cw4", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw4" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aacb0124dce37ee6f2b5636684285bcbaa65a1678980f95ea76366ab74a8912" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "schemars", + "serde", +] + +[[package]] +name = "abstract-cw4-group" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0af5ef484ba1d48fee8485452c81ac3465ba16a5941db90bda4dd6b58b50a9a6" +dependencies = [ + "abstract-cw2", + "abstract-cw4", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw4-stake" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1eb9985e8b752396a2c5d8fde8ebf65ea81070a95f167a3d31af0746f8e4b4e" +dependencies = [ + "abstract-cw2", + "abstract-cw20", + "abstract-cw4", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "addr2line" version = "0.22.0" @@ -68,7 +335,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -79,7 +346,7 @@ checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -184,6 +451,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" + [[package]] name = "bindgen" version = "0.68.1" @@ -203,7 +476,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.72", + "syn 2.0.74", "which", ] @@ -333,18 +606,21 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.1" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" dependencies = [ "serde", ] [[package]] name = "cc" -version = "1.1.6" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aba8f4e9906c7ce3c73463f62a7f0c65183ada1a2d47e397cc8810827f9694f" +checksum = "5fb8dd288a69fc53a1996d7ecfbf4a20d59065bff137ce7e56bbd620de191189" +dependencies = [ + "shlex", +] [[package]] name = "cexpr" @@ -406,6 +682,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -418,9 +703,9 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cosm-orc" @@ -489,6 +774,17 @@ dependencies = [ "tonic 0.9.2", ] +[[package]] +name = "cosmos-sdk-proto" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e23f6ab56d5f031cde05b8b82a5fefd3a1a223595c79e32317a97189e612bc" +dependencies = [ + "prost 0.12.3", + "prost-types 0.12.3", + "tendermint-proto 0.35.0", +] + [[package]] name = "cosmrs" version = "0.9.0" @@ -533,32 +829,31 @@ dependencies = [ [[package]] name = "cosmwasm-crypto" -version = "1.5.5" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd50718a2b6830ce9eb5d465de5a018a12e71729d66b70807ce97e6dd14f931d" +checksum = "0f862b355f7e47711e0acfe6af92cb3fd8fd5936b66a9eaa338b51edabd1e77d" dependencies = [ "digest 0.10.7", - "ecdsa 0.16.9", "ed25519-zebra", - "k256 0.13.1", + "k256 0.13.3", "rand_core 0.6.4", "thiserror", ] [[package]] name = "cosmwasm-derive" -version = "1.5.5" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "242e98e7a231c122e08f300d9db3262d1007b51758a8732cd6210b3e9faa4f3a" +checksum = "cd85de6467cd1073688c86b39833679ae6db18cf4771471edd9809f15f1679f1" dependencies = [ "syn 1.0.109", ] [[package]] name = "cosmwasm-schema" -version = "1.5.5" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7879036156092ad1c22fe0d7316efc5a5eceec2bc3906462a2560215f2a2f929" +checksum = "5b4cd28147a66eba73720b47636a58097a979ad8c8bfdb4ed437ebcbfe362576" dependencies = [ "cosmwasm-schema-derive", "schemars", @@ -569,9 +864,9 @@ dependencies = [ [[package]] name = "cosmwasm-schema-derive" -version = "1.5.5" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb57855fbfc83327f8445ae0d413b1a05ac0d68c396ab4d122b2abd7bb82cb6" +checksum = "9acd45c63d41bc9b16bc6dc7f6bd604a8c2ad29ce96c8f3c96d7fc8ef384392e" dependencies = [ "proc-macro2", "quote", @@ -580,12 +875,12 @@ dependencies = [ [[package]] name = "cosmwasm-std" -version = "1.5.5" +version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c1556156fdf892a55cced6115968b961eaaadd6f724a2c2cb7d1e168e32dd3" +checksum = "2685c2182624b2e9e17f7596192de49a3f86b7a0c9a5f6b25c1df5e24592e836" dependencies = [ "base64 0.21.7", - "bech32", + "bech32 0.9.1", "bnum", "cosmwasm-crypto", "cosmwasm-derive", @@ -612,9 +907,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" dependencies = [ "libc", ] @@ -688,7 +983,7 @@ dependencies = [ name = "cw-admin-factory" version = "2.5.0" dependencies = [ - "bech32", + "bech32 0.9.1", "cosmwasm-schema", "cosmwasm-std", "cw-admin-factory", @@ -891,7 +1186,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc392a5cb7e778e3f90adbf7faa43c4db7f35b6623224b08886d796718edb875" dependencies = [ "anyhow", - "bech32", + "bech32 0.9.1", "cosmwasm-std", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", @@ -904,6 +1199,96 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw-orch" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1ddc937c28c59ccf2765fa05ddc0437644d3b283408a7cc64f7b371b0b9309" +dependencies = [ + "anyhow", + "cosmwasm-std", + "cw-orch-contract-derive", + "cw-orch-core", + "cw-orch-fns-derive", + "cw-orch-mock", + "cw-orch-traits", + "cw-utils 1.0.3", + "hex", + "log", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-orch-contract-derive" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc8ba75692fc7bd30e91c78fad2dc208a738e4e6ea26b232f9352c320e35543" +dependencies = [ + "convert_case", + "quote", + "syn 2.0.74", +] + +[[package]] +name = "cw-orch-core" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abd640f67f655411542a3c148769270c7a9e63d4097c2dc4a1f6edf23b7a9b4" +dependencies = [ + "abstract-cw-multi-test", + "anyhow", + "cosmos-sdk-proto 0.21.1", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "dirs", + "log", + "serde", + "serde_json", + "sha2 0.10.8", + "thiserror", +] + +[[package]] +name = "cw-orch-fns-derive" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9acb7a15bfacc52abdf312a9fffb139883c1effb6ea7e645cd39580a8527463" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cw-orch-mock" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae9536620b86ee78c2729fd8449538feb4f6257a9809c72c5f9e461e720cf3b" +dependencies = [ + "abstract-cw-multi-test", + "cosmwasm-std", + "cw-orch-core", + "cw-utils 1.0.3", + "log", + "serde", + "sha2 0.10.8", +] + +[[package]] +name = "cw-orch-traits" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5959ce29e9d8a52594b47933a0a2736ea94dd9bf5e29b220cbdbe2b097f07c3a" +dependencies = [ + "cw-orch-core", + "prost 0.12.3", + "prost-types 0.12.3", +] + [[package]] name = "cw-ownable" version = "0.5.1" @@ -1339,6 +1724,25 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw20-ics20" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76221201da08fed611c857ea3aa21c031a4a7dc771a8b1750559ca987335dc02" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw20 1.1.2", + "schemars", + "semver", + "serde", + "thiserror", +] + [[package]] name = "cw20-stake" version = "0.2.6" @@ -2572,6 +2976,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dlv-list" version = "0.3.0" @@ -2846,7 +3271,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -2879,6 +3304,53 @@ dependencies = [ "slab", ] +[[package]] +name = "gauge-adapter" +version = "2.5.0" +dependencies = [ + "abstract-cw-plus-interface", + "abstract-cw20", + "abstract-cw20-base", + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom 2.5.0", + "cw-multi-test", + "cw-orch", + "cw-orch-core", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw20 1.1.2", + "cw20-base 1.1.2", + "semver", + "thiserror", +] + +[[package]] +name = "gauge-orchestrator" +version = "2.5.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw4 1.1.2", + "cw4-group 1.1.2", + "dao-dao-core 2.5.0", + "dao-hooks 2.5.0", + "dao-interface 2.5.0", + "dao-proposal-single 2.5.0", + "dao-voting 2.5.0", + "dao-voting-cw4 2.5.0", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2949,7 +3421,7 @@ dependencies = [ "futures-sink", "futures-util", "http", - "indexmap 2.2.6", + "indexmap 2.4.0", "slab", "tokio", "tokio-util", @@ -3001,6 +3473,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hermit-abi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" + [[package]] name = "hex" version = "0.4.3" @@ -3179,9 +3657,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" dependencies = [ "equivalent", "hashbrown 0.14.5", @@ -3223,11 +3701,11 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" dependencies = [ - "hermit-abi", + "hermit-abi 0.4.0", "libc", "windows-sys 0.52.0", ] @@ -3241,6 +3719,15 @@ 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 = "itertools" version = "0.12.1" @@ -3258,9 +3745,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" dependencies = [ "wasm-bindgen", ] @@ -3291,9 +3778,9 @@ dependencies = [ [[package]] name = "k256" -version = "0.13.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadb76004ed8e97623117f3df85b17aaa6626ab0b0831e6573f104df16cd1bcc" +checksum = "956ff9b67e26e1a6a866cb758f12c6f8746208489e3e4a4b5580802f2f0a587b" dependencies = [ "cfg-if", "ecdsa 0.16.9", @@ -3340,6 +3827,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -3393,13 +3890,14 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.11" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi 0.3.9", "libc", "wasi", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -3424,22 +3922,23 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "num-derive" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ - "autocfg", + "proc-macro2", + "quote", + "syn 2.0.74", ] [[package]] -name = "num_cpus" -version = "1.16.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "hermit-abi", - "libc", + "autocfg", ] [[package]] @@ -3453,9 +3952,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.1" +version = "0.36.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" +checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" dependencies = [ "memchr", ] @@ -3507,6 +4006,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -3655,7 +4160,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -3686,7 +4191,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -3723,9 +4228,12 @@ dependencies = [ [[package]] name = "ppv-lite86" -version = "0.2.17" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] [[package]] name = "prettyplease" @@ -3734,7 +4242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -3798,10 +4306,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efb6c9a1dd1def8e2124d17e83a20af56f1570d6c2d2bd9e266ccb768df3840e" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -3867,11 +4375,22 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_users" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" -version = "1.10.5" +version = "1.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ "aho-corasick", "memchr", @@ -4074,7 +4593,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -4146,9 +4665,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.204" +version = "1.0.207" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" dependencies = [ "serde_derive", ] @@ -4182,13 +4701,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.207" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -4199,16 +4718,17 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] name = "serde_json" -version = "1.0.120" +version = "1.0.124" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] @@ -4221,7 +4741,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -4230,7 +4750,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.2.6", + "indexmap 2.4.0", "itoa", "ryu", "serde", @@ -4445,9 +4965,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.72" +version = "2.0.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" +checksum = "1fceb41e3d546d0bd83421d3409b1460cc7444cd389341a4c880fe7a042cb3d7" dependencies = [ "proc-macro2", "quote", @@ -4558,7 +5078,7 @@ checksum = "68ce80bf536476db81ecc9ebab834dc329c9c1509a694f211a73858814bfe023" dependencies = [ "bytes", "flex-error", - "num-derive", + "num-derive 0.3.3", "num-traits", "prost 0.11.9", "prost-types 0.11.9", @@ -4576,7 +5096,7 @@ checksum = "974d6330a19dfa6720e9f663fc59101d207a817db3f9c730d3f31caaa565b574" dependencies = [ "bytes", "flex-error", - "num-derive", + "num-derive 0.3.3", "num-traits", "prost 0.11.9", "prost-types 0.11.9", @@ -4594,7 +5114,7 @@ checksum = "c0cec054567d16d85e8c3f6a3139963d1a66d9d3051ed545d31562550e9bcc3d" dependencies = [ "bytes", "flex-error", - "num-derive", + "num-derive 0.3.3", "num-traits", "prost 0.11.9", "prost-types 0.11.9", @@ -4604,6 +5124,24 @@ dependencies = [ "time", ] +[[package]] +name = "tendermint-proto" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff525d5540a9fc535c38dc0d92a98da3ee36fcdfbda99cecb9f3cce5cd4d41d7" +dependencies = [ + "bytes", + "flex-error", + "num-derive 0.4.2", + "num-traits", + "prost 0.12.3", + "prost-types 0.12.3", + "serde", + "serde_bytes", + "subtle-encoding", + "time", +] + [[package]] name = "tendermint-rpc" version = "0.23.9" @@ -4698,7 +5236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d506c7664333e246f564949bee4ed39062aa0f11918e6f5a95f553cdad65c274" dependencies = [ "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -4734,7 +5272,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -4771,19 +5309,18 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.1" +version = "1.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df" +checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1" dependencies = [ "backtrace", "bytes", "libc", "mio", - "num_cpus", "pin-project-lite", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -4798,13 +5335,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -4933,15 +5470,15 @@ dependencies = [ [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -4962,7 +5499,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] [[package]] @@ -5023,6 +5560,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -5054,9 +5597,9 @@ checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" [[package]] name = "version_check" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vote-hooks" @@ -5097,34 +5640,35 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" dependencies = [ "cfg-if", + "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5132,28 +5676,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" dependencies = [ "js-sys", "wasm-bindgen", @@ -5208,11 +5752,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5239,6 +5783,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5381,6 +5934,27 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.74", +] + [[package]] name = "zeroize" version = "1.8.1" @@ -5398,5 +5972,5 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.72", + "syn 2.0.74", ] diff --git a/Cargo.toml b/Cargo.toml index 1d98d3a94..723158773 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/dao-dao-core", "contracts/distribution/*", "contracts/external/*", + "contracts/gauges/*", "contracts/proposal/*", "contracts/pre-propose/*", "contracts/staking/*", @@ -80,6 +81,9 @@ syn = { version = "1.0", features = ["derive"] } test-context = "0.1" thiserror = { version = "1.0" } wynd-utils = "0.4" +cw-orch = "0.22.2" +cw-orch-core = "1.0.0-rc" + # One commit ahead of version 0.3.0. Allows initialization with an # optional owner. @@ -125,6 +129,8 @@ dao-voting-cw721-roles = { path = "./contracts/voting/dao-voting-cw721-roles", v dao-voting-cw721-staked = { path = "./contracts/voting/dao-voting-cw721-staked", version = "2.5.0" } dao-voting-onft-staked = { path = "./contracts/voting/dao-voting-onft-staked", version = "2.5.0" } dao-voting-token-staked = { path = "./contracts/voting/dao-voting-token-staked", version = "2.5.0" } +gauge-orchestrator = { path = "./contracts/gauges/gauge", version = "2.5.0" } +gauge-adapter = { path = "./contracts/gauges/gauge-adapter", version = "2.5.0" } # v1 dependencies. used for state migrations. cw-core-v1 = { package = "cw-core", version = "0.1.0" } diff --git a/contracts/external/cw-tokenfactory-issuer/Cargo.toml b/contracts/external/cw-tokenfactory-issuer/Cargo.toml index 2abbb4180..d3b7e6e3e 100644 --- a/contracts/external/cw-tokenfactory-issuer/Cargo.toml +++ b/contracts/external/cw-tokenfactory-issuer/Cargo.toml @@ -14,7 +14,6 @@ repository = { workspace = true } # the default DAO DAO license license = "Apache-2.0" - 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", diff --git a/contracts/gauges/LICENSE b/contracts/gauges/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/contracts/gauges/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/contracts/gauges/NOTICE b/contracts/gauges/NOTICE new file mode 100644 index 000000000..c3b75302e --- /dev/null +++ b/contracts/gauges/NOTICE @@ -0,0 +1,18 @@ +WyndDAO: Smart contracts for the $WYND token and Wynd DAO + +Copyright (C) 2022 Cosmorama + +Some contracts derived from Apache 2.0 work from other authors, please check the +NOTICE files in those packages for attributions. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/contracts/gauges/README.md b/contracts/gauges/README.md new file mode 100644 index 000000000..68c610ec7 --- /dev/null +++ b/contracts/gauges/README.md @@ -0,0 +1,3 @@ +# Gauges + +Forked from [Wynd DAO repo](https://github.com/wynddao/wynddao), modified to support any type of DAO. diff --git a/contracts/gauges/gauge-adapter/.cargo/config b/contracts/gauges/gauge-adapter/.cargo/config new file mode 100644 index 000000000..de2d36ac7 --- /dev/null +++ b/contracts/gauges/gauge-adapter/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +wasm-debug = "build --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/gauges/gauge-adapter/Cargo.toml b/contracts/gauges/gauge-adapter/Cargo.toml new file mode 100644 index 000000000..f8989d57d --- /dev/null +++ b/contracts/gauges/gauge-adapter/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "gauge-adapter" +authors = ["Jakub "] +version = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } + +# This repo was fork from https://github.com/wynddao/wynddao and does not use +# the default DAO DAO license, git history has been retained +license = "Apache-2.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 } +cw-denom = { workspace = true } +cw-orch = { workspace = true } +cw-utils = { workspace = true } +semver = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +abstract-cw-plus-interface = "2.0.1" +abstract-cw20 = "2.0.0" +abstract-cw20-base = "2.0.0" +anyhow = { workspace = true } +cw-multi-test = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +cw-orch-core = { workspace = true } diff --git a/contracts/gauges/gauge-adapter/README.md b/contracts/gauges/gauge-adapter/README.md new file mode 100644 index 000000000..a1fcf92e1 --- /dev/null +++ b/contracts/gauges/gauge-adapter/README.md @@ -0,0 +1,31 @@ +# Marketing Gauge Adapter Contract + +This is an adapter contract for use in conjunction with the [gauge contract](../gauge/README.md). + +The purpose of this adapter is to allow people to apply for a reward. The total reward amount is set during contract instantiation and will be divided among applicants based on community votes. + +## Implementation + +The basic structure containing all information required for an application is: + +```rust +CreateSubmission { + name: String, + url: String, + address: String, +} +``` + +Depending on how the gauge contract is instantiated a spam preventing deposit can be required to create a submission. This is specified by the field `required_deposit` in the `Config` structure. + +The contract can receive 3 kind of messages to execute the contract logic: + +1. Create a submission by sending CW20 tokens routed through the CW20 contract. + +2. Create a submission by sending native tokens. + +3. Return all submission's deposit to the address specified during submission. This logic can be triggered only by the contract's `admin` specified during gauge instantiation and saved in the `Config` structure. + +## Options + +Options represent all the addresses that have been stored through the field `address` of the `CreateSubmission` structure. diff --git a/contracts/gauges/gauge-adapter/src/bin/schema.rs b/contracts/gauges/gauge-adapter/src/bin/schema.rs new file mode 100644 index 000000000..e40526000 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/bin/schema.rs @@ -0,0 +1,13 @@ +use cosmwasm_schema::write_api; + +use cosmwasm_std::Empty; +use gauge_adapter::msg::{AdapterQueryMsg, InstantiateMsg, MigrateMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: Empty, + query: AdapterQueryMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/gauges/gauge-adapter/src/contract.rs b/contracts/gauges/gauge-adapter/src/contract.rs new file mode 100644 index 000000000..fdcbec92d --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/contract.rs @@ -0,0 +1,444 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + from_json, to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, + StdResult, Uint128, +}; +use cw2::set_contract_version; +use cw20::Cw20ReceiveMsg; +use cw_denom::UncheckedDenom; +use cw_utils::{one_coin, PaymentError}; + +use crate::{ + error::ContractError, + msg::{AdapterQueryMsg, AssetUnchecked, ExecuteMsg, InstantiateMsg, MigrateMsg, ReceiveMsg}, + state::{Config, Submission, CONFIG, SUBMISSIONS}, +}; + +// Version info for migration info. +const CONTRACT_NAME: &str = "crates.io:marketing-gauge-adapter"; +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 community_pool = deps.api.addr_validate(&msg.community_pool)?; + SUBMISSIONS.save( + deps.storage, + community_pool.clone(), + &Submission { + sender: env.contract.address, + name: "Unimpressed".to_owned(), + url: "Those funds go back to the community pool".to_owned(), + }, + )?; + + let config = Config { + admin: deps.api.addr_validate(&msg.admin)?, + required_deposit: msg + .required_deposit + .map(|x| x.into_checked(deps.as_ref())) + .transpose()?, + community_pool, + reward: msg.reward.into_checked(deps.as_ref())?, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Receive(msg) => receive_cw20_message(deps, info, msg), + ExecuteMsg::CreateSubmission { name, url, address } => { + let received = match one_coin(&info) { + Ok(coin) => Ok(Some(coin)), + Err(PaymentError::NoFunds {}) => Ok(None), + Err(error) => Err(error), + }? + .map(|x| AssetUnchecked { + denom: UncheckedDenom::Native(x.denom), + amount: x.amount, + }); + + execute::create_submission(deps, info.sender, name, url, address, received) + } + ExecuteMsg::ReturnDeposits {} => execute::return_deposits(deps, info.sender), + } +} + +fn receive_cw20_message( + deps: DepsMut, + info: MessageInfo, + msg: Cw20ReceiveMsg, +) -> Result { + match from_json(&msg.msg)? { + ReceiveMsg::CreateSubmission { name, url, address } => execute::create_submission( + deps, + Addr::unchecked(msg.sender), + name, + url, + address, + Some(AssetUnchecked::new_cw20( + info.sender.as_str(), + msg.amount.u128(), + )), + ), + } +} + +pub mod execute { + use super::*; + + use cosmwasm_std::{ensure_eq, CosmosMsg}; + + pub fn create_submission( + deps: DepsMut, + sender: Addr, + name: String, + url: String, + address: String, + received: Option, + ) -> Result { + let address = deps.api.addr_validate(&address)?; + + let Config { + required_deposit, + community_pool: _, + reward: _, + admin: _, + } = CONFIG.load(deps.storage)?; + if let Some(required_deposit) = required_deposit { + if let Some(received) = received { + let received_denom = received.denom.into_checked(deps.as_ref())?; + + if required_deposit.denom != received_denom { + return Err(ContractError::InvalidDepositType {}); + } + if received.amount != required_deposit.amount { + return Err(ContractError::InvalidDepositAmount { + correct_amount: required_deposit.amount, + }); + } + } else { + return Err(ContractError::PaymentError(PaymentError::NoFunds {})); + } + } else if let Some(received) = received { + // If no deposit is required, then any deposit invalidates a submission. + if !received.amount.is_zero() { + return Err(ContractError::InvalidDepositAmount { + correct_amount: Uint128::zero(), + }); + } + } + + // allow to overwrite submission by the same author + if let Some(old_submission) = SUBMISSIONS.may_load(deps.storage, address.clone())? { + if old_submission.sender != sender { + return Err(ContractError::UnauthorizedSubmission {}); + } + } + + SUBMISSIONS.save(deps.storage, address, &Submission { sender, name, url })?; + Ok(Response::new().add_attribute("create", "submission")) + } + + pub fn return_deposits(deps: DepsMut, sender: Addr) -> Result { + let Config { + admin, + required_deposit, + community_pool: _, + reward: _, + } = CONFIG.load(deps.storage)?; + + // No refund if no deposit was required. + let required_deposit = required_deposit.ok_or(ContractError::NoDepositToRefund {})?; + + ensure_eq!(sender, admin, ContractError::Unauthorized {}); + + let msgs = SUBMISSIONS + .range(deps.storage, None, None, Order::Ascending) + .map(|item| { + let (_submission_recipient, submission) = item?; + + required_deposit + .denom + .get_transfer_to_message(&submission.sender, required_deposit.amount) + }) + .collect::>>()?; + + Ok(Response::new().add_messages(msgs)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: AdapterQueryMsg) -> StdResult { + match msg { + AdapterQueryMsg::Config {} => to_json_binary(&CONFIG.load(deps.storage)?), + AdapterQueryMsg::AllOptions {} => to_json_binary(&query::all_options(deps)?), + AdapterQueryMsg::CheckOption { option } => { + to_json_binary(&query::check_option(deps, option)?) + } + AdapterQueryMsg::SampleGaugeMsgs { selected } => { + to_json_binary(&query::sample_gauge_msgs(deps, selected)?) + } + AdapterQueryMsg::Submission { address } => { + to_json_binary(&query::submission(deps, address)?) + } + AdapterQueryMsg::AllSubmissions {} => to_json_binary(&query::all_submissions(deps)?), + } +} + +mod query { + use cosmwasm_std::{CosmosMsg, Decimal, StdError}; + + use crate::msg::{ + AllOptionsResponse, AllSubmissionsResponse, CheckOptionResponse, SampleGaugeMsgsResponse, + SubmissionResponse, + }; + + use super::*; + + pub fn all_options(deps: Deps) -> StdResult { + Ok(AllOptionsResponse { + options: SUBMISSIONS + .keys(deps.storage, None, None, Order::Ascending) + .map(|key| Ok(key?.to_string())) + .collect::>>()?, + }) + } + + pub fn check_option(deps: Deps, option: String) -> StdResult { + Ok(CheckOptionResponse { + valid: SUBMISSIONS.has(deps.storage, deps.api.addr_validate(&option)?), + }) + } + + pub fn sample_gauge_msgs( + deps: Deps, + winners: Vec<(String, Decimal)>, + ) -> StdResult { + let reward = CONFIG.load(deps.storage)?.reward; + + let execute = winners + .into_iter() + .map(|(to_address, fraction)| { + // Gauge already sends chosen tally to this query by using results we send in + // all_options query; they are already validated + let to_address = deps.api.addr_validate(&to_address)?; + + reward.denom.get_transfer_to_message( + &to_address, + reward + .amount + .checked_mul_floor(fraction) + .map_err(|x| StdError::generic_err(x.to_string()))?, + ) + }) + .collect::>>()?; + Ok(SampleGaugeMsgsResponse { execute }) + } + + pub fn submission(deps: Deps, address: String) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let submission = SUBMISSIONS.load(deps.storage, address.clone())?; + Ok(SubmissionResponse { + sender: submission.sender, + name: submission.name, + url: submission.url, + address, + }) + } + + pub fn all_submissions(deps: Deps) -> StdResult { + Ok(AllSubmissionsResponse { + submissions: SUBMISSIONS + .range(deps.storage, None, None, Order::Ascending) + .map(|s| { + let (address, submission) = s?; + Ok(SubmissionResponse { + sender: submission.sender, + name: submission.name, + url: submission.url, + address, + }) + }) + .collect::>>()?, + }) + } +} + +/// Manages the contract migration. +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + Ok(Response::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use cosmwasm_std::{ + coins, + testing::{mock_dependencies, mock_env, mock_info}, + BankMsg, CosmosMsg, Decimal, Uint128, + }; + use cw_denom::CheckedDenom; + + use crate::{msg::AssetUnchecked, state::Asset}; + + #[test] + fn proper_initialization() { + let mut deps = mock_dependencies(); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: Some(AssetUnchecked::new_native("wynd", 10_000_000)), + community_pool: "community".to_owned(), + reward: AssetUnchecked::new_native("ujuno", 150_000_000_000), + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + + // Check if the config is stored. + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.admin, Addr::unchecked("admin")); + assert_eq!( + config.required_deposit, + Some(Asset { + denom: CheckedDenom::Native(String::from("wynd")), + amount: Uint128::new(10_000_000) + }) + ); + assert_eq!(config.community_pool, "community".to_owned()); + assert_eq!( + config.reward, + Asset { + denom: CheckedDenom::Native("ujuno".to_owned()), + amount: Uint128::new(150_000_000_000) + } + ); + + let msg = InstantiateMsg { + reward: AssetUnchecked::new_native("ujuno", 10_000_000), + ..msg + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!( + config.reward, + Asset { + denom: CheckedDenom::Native("ujuno".to_owned()), + amount: Uint128::new(10_000_000) + } + ); + + let msg = InstantiateMsg { + required_deposit: None, + ..msg + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.required_deposit, None); + } + + #[test] + fn sample_gauge_msgs_native() { + let mut deps = mock_dependencies(); + + let reward = Uint128::new(150_000_000_000); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: Some(AssetUnchecked::new_native("wynd", 10_000_000)), + community_pool: "community".to_owned(), + reward: AssetUnchecked::new_native("ujuno", reward.into()), + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + + let selected = vec![ + ( + "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + Decimal::percent(41), + ), + ( + "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + Decimal::percent(33), + ), + ( + "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + Decimal::percent(26), + ), + ]; + let res = query::sample_gauge_msgs(deps.as_ref(), selected).unwrap(); + assert_eq!(res.execute.len(), 3); + assert_eq!( + res.execute, + [ + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + amount: coins((reward * Decimal::percent(41)).u128(), "ujuno") + }), + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + amount: coins((reward * Decimal::percent(33)).u128(), "ujuno") + }), + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + amount: coins((reward * Decimal::percent(26)).u128(), "ujuno") + }), + ] + ); + } + + #[test] + fn return_deposits_authorization() { + let mut deps = mock_dependencies(); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: None, + community_pool: "community".to_owned(), + reward: AssetUnchecked::new_native("ujuno", 150_000_000_000), + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + + let err = execute::return_deposits(deps.as_mut(), Addr::unchecked("user")).unwrap_err(); + assert_eq!(err, ContractError::NoDepositToRefund {}); + + let msg = InstantiateMsg { + required_deposit: Some(AssetUnchecked::new_native("ujuno", 10_000_000)), + ..msg + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + + let err = execute::return_deposits(deps.as_mut(), Addr::unchecked("user")).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } +} diff --git a/contracts/gauges/gauge-adapter/src/error.rs b/contracts/gauges/gauge-adapter/src/error.rs new file mode 100644 index 000000000..6994dc494 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/error.rs @@ -0,0 +1,31 @@ +use cosmwasm_std::{StdError, Uint128}; +use cw_denom::DenomError; +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("{0}")] + DenomError(#[from] DenomError), + + #[error("Operation unauthorized - only admin can release deposits")] + Unauthorized {}, + + #[error("Operation unauthorized - there's already existing submission for that destination address; only previous sender can overwrite it")] + UnauthorizedSubmission {}, + + #[error("Invalid submission - required deposit set in incorrect denom")] + InvalidDepositType {}, + + #[error("Invalid submission - invalid amount for required deposit. Either multiple denoms were sent or amount does not match {correct_amount}")] + InvalidDepositAmount { correct_amount: Uint128 }, + + #[error("No deposit was required, therefore no deposit can be returned")] + NoDepositToRefund {}, +} diff --git a/contracts/gauges/gauge-adapter/src/helpers.rs b/contracts/gauges/gauge-adapter/src/helpers.rs new file mode 100644 index 000000000..862be36d4 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/helpers.rs @@ -0,0 +1,27 @@ +use cosmwasm_std::Deps; +use cw_denom::{DenomError, UncheckedDenom}; + +use crate::{msg::AssetUnchecked, state::Asset}; + +impl AssetUnchecked { + pub fn into_checked(self, deps: Deps) -> Result { + Ok(Asset { + denom: self.denom.into_checked(deps)?, + amount: self.amount, + }) + } + + pub fn new_native(denom: &str, amount: u128) -> Self { + Self { + denom: UncheckedDenom::Native(denom.to_owned()), + amount: amount.into(), + } + } + + pub fn new_cw20(denom: &str, amount: u128) -> Self { + Self { + denom: UncheckedDenom::Cw20(denom.to_owned()), + amount: amount.into(), + } + } +} diff --git a/contracts/gauges/gauge-adapter/src/lib.rs b/contracts/gauges/gauge-adapter/src/lib.rs new file mode 100644 index 000000000..aae805d76 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/lib.rs @@ -0,0 +1,10 @@ +pub mod contract; +mod error; +mod helpers; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod multitest; + +pub use crate::error::ContractError; diff --git a/contracts/gauges/gauge-adapter/src/msg.rs b/contracts/gauges/gauge-adapter/src/msg.rs new file mode 100644 index 000000000..5d8a16f7d --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/msg.rs @@ -0,0 +1,105 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, CosmosMsg, Decimal, Uint128}; +use cw20::Cw20ReceiveMsg; +use cw_denom::UncheckedDenom; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address that is allowed to return deposits. + pub admin: String, + /// Deposit required for valid submission. This option allows to reduce spam. + pub required_deposit: Option, + /// Address of contract where each deposit is transferred. + pub community_pool: String, + /// Total reward amount. + pub reward: AssetUnchecked, +} + +#[cw_serde] +#[derive(cw_orch::ExecuteFns)] +pub enum ExecuteMsg { + /// Implements the Cw20 receiver interface. + Receive(Cw20ReceiveMsg), + /// Save info about team that wants to participate. + /// Only for native tokens as required deposit. + CreateSubmission { + name: String, + url: String, + address: String, + }, + /// Sends back all deposit to senders. + ReturnDeposits {}, +} + +#[cw_serde] +pub enum ReceiveMsg { + /// Save info about team that wants to participate. + /// Only for CW20 tokens as required deposit. + CreateSubmission { + name: String, + url: String, + address: String, + }, +} + +#[cw_serde] +pub enum MigrateMsg {} + +// Queries copied from gauge-orchestrator for now (we could use a common crate for this). +/// Queries the gauge requires from the adapter contract in order to function. +#[cw_serde] +#[derive(QueryResponses, cw_orch::QueryFns)] +pub enum AdapterQueryMsg { + #[returns(crate::state::Config)] + Config {}, + #[returns(AllOptionsResponse)] + AllOptions {}, + #[returns(CheckOptionResponse)] + CheckOption { option: String }, + #[returns(SampleGaugeMsgsResponse)] + SampleGaugeMsgs { + /// Option along with weight. + /// Sum of all weights should be 1.0 (within rounding error). + selected: Vec<(String, Decimal)>, + }, + + // Marketing-gauge specific queries to help on frontend + #[returns(SubmissionResponse)] + Submission { address: String }, + #[returns(AllSubmissionsResponse)] + AllSubmissions {}, +} + +#[cw_serde] +pub struct AllOptionsResponse { + pub options: Vec, +} + +#[cw_serde] +pub struct CheckOptionResponse { + pub valid: bool, +} + +#[cw_serde] +pub struct SampleGaugeMsgsResponse { + pub execute: Vec, +} + +#[cw_serde] +pub struct SubmissionResponse { + pub sender: Addr, + pub name: String, + pub url: String, + pub address: Addr, +} + +#[cw_serde] +pub struct AllSubmissionsResponse { + pub submissions: Vec, +} + +#[cw_serde] +pub struct AssetUnchecked { + pub denom: UncheckedDenom, + pub amount: Uint128, +} diff --git a/contracts/gauges/gauge-adapter/src/multitest/mod.rs b/contracts/gauges/gauge-adapter/src/multitest/mod.rs new file mode 100644 index 000000000..e5a2ee02d --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/multitest/mod.rs @@ -0,0 +1,4 @@ +mod suite; + +mod options; +mod submission; diff --git a/contracts/gauges/gauge-adapter/src/multitest/options.rs b/contracts/gauges/gauge-adapter/src/multitest/options.rs new file mode 100644 index 000000000..2d6343cff --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/multitest/options.rs @@ -0,0 +1,80 @@ +use cosmwasm_std::{coin, coins}; +use cw_denom::UncheckedDenom; +use cw_orch::{contract::interface_traits::CwOrchQuery, mock::MockBech32}; + +use crate::{ + msg::{ + AdapterQueryMsg, AllOptionsResponse, AllSubmissionsResponse, AssetUnchecked, + CheckOptionResponse, + }, + multitest::suite::{native_submission_helper, setup_gauge_adapter}, +}; + +#[test] +fn option_queries() { + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); + + let recipient = mock.addr_make("recipient"); + let newton = mock.addr_make("newton"); + let einstein = mock + .addr_make_with_balance("einstein", coins(1_000, "juno")) + .unwrap(); + + mock.add_balance(&mock.sender, coins(1_000, "juno")) + .unwrap(); + let options: AllSubmissionsResponse = + adapter.query(&AdapterQueryMsg::AllSubmissions {}).unwrap(); + // account for a default option + assert_eq!(options.submissions.len(), 1); + + // Valid submission. + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); + + // Valid submission. + native_submission_helper( + adapter.clone(), + einstein.clone(), + einstein.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); + + let options: AllOptionsResponse = adapter.query(&AdapterQueryMsg::AllOptions {}).unwrap(); + assert_eq!( + options, + AllOptionsResponse { + options: vec![ + einstein.to_string(), + mock.addr_make("community_pool").to_string(), + recipient.to_string() + ] + }, + ); + + let option: CheckOptionResponse = adapter + .query(&AdapterQueryMsg::CheckOption { + option: einstein.to_string(), + }) + .unwrap(); + assert!(option.valid); + + let option: CheckOptionResponse = adapter + .query(&AdapterQueryMsg::CheckOption { + option: newton.to_string(), + }) + .unwrap(); + assert!(!option.valid); +} diff --git a/contracts/gauges/gauge-adapter/src/multitest/submission.rs b/contracts/gauges/gauge-adapter/src/multitest/submission.rs new file mode 100644 index 000000000..41f8a88cb --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/multitest/submission.rs @@ -0,0 +1,586 @@ +use crate::{ + msg::{ + AdapterQueryMsg, AdapterQueryMsgFns, AllSubmissionsResponse, AssetUnchecked, ExecuteMsg, + ExecuteMsgFns, ReceiveMsg, SubmissionResponse, + }, + multitest::suite::{ + cw20_helper, native_submission_helper, setup_cw20_reward_gauge_adapter, setup_gauge_adapter, + }, + ContractError, +}; + +use abstract_cw20::{msg::Cw20ExecuteMsgFns, Cw20ExecuteMsg as AbsCw20ExecuteMsg}; +use abstract_cw20_base::msg::QueryMsgFns; +use cosmwasm_std::{coin, to_json_binary, Addr, CosmosMsg, Decimal, Uint128, WasmMsg}; +use cw_denom::UncheckedDenom; +use cw_orch::{contract::interface_traits::CwOrchExecute, mock::MockBech32, prelude::*}; + +#[test] +fn create_default_submission() { + let mock = MockBech32::new("mock"); + let treasury = &mock.addr_make("community_pool"); + + let adapter = setup_gauge_adapter(mock.clone(), None); + + // this one is created by default during instantiation + assert_eq!( + SubmissionResponse { + sender: adapter.address().unwrap(), + name: "Unimpressed".to_owned(), + url: "Those funds go back to the community pool".to_owned(), + address: treasury.clone(), + }, + adapter + .query(&crate::msg::AdapterQueryMsg::Submission { + address: treasury.to_string() + }) + .unwrap() + ) +} + +#[test] +fn create_submission_no_required_deposit() { + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter(mock.clone(), None); + + let recipient = mock.addr_make("recipient"); + mock.add_balance(&mock.sender, vec![coin(1_000, "juno")]) + .unwrap(); + + // Fails send funds along with the tx. + let err = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(coin(1_000, "juno")), + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::zero() + }, + err.downcast().unwrap() + ); + + // Valid submission. + let result = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + None, + ); + assert!(result.is_ok()); + + assert_eq!( + SubmissionResponse { + sender: mock.sender, + name: "DAOers".to_owned(), + url: "https://daodao.zone".to_owned(), + address: recipient.clone(), + }, + adapter + .query(&crate::msg::AdapterQueryMsg::Submission { + address: recipient.to_string() + }) + .unwrap(), + ) +} + +#[test] +fn overwrite_existing_submission() { + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter(mock.clone(), None); + let recipient = mock.addr_make("recipient"); + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + None, + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: mock.sender.clone(), + name: "DAOers".to_owned(), + url: "https://daodao.zone".to_string(), + address: recipient.clone(), + }, + adapter.submission(recipient.to_string()).unwrap() + ); + + // Try to submit to the same address with different user + let err = native_submission_helper( + adapter.clone(), + Addr::unchecked("anotheruser"), + recipient.clone(), + None, + ) + .unwrap_err(); + + assert_eq!( + ContractError::UnauthorizedSubmission {}, + err.downcast().unwrap() + ); + + // Overwriting submission as same author works + native_submission_helper(adapter.clone(), mock.sender, recipient.clone(), None).unwrap(); + + let response = adapter.submission(recipient.to_string()).unwrap(); + assert_eq!(response.url, "https://daodao.zone".to_owned()); +} + +#[test] +fn create_submission_required_deposit() { + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); + + let recipient = mock.addr_make("recipient"); + mock.add_balance(&mock.sender.clone(), vec![coin(1_000, "wynd")]) + .unwrap(); + mock.add_balance(&mock.sender.clone(), vec![coin(1_000, "juno")]) + .unwrap(); + + // Fails if no funds sent. + let err = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + None, + ) + .unwrap_err(); + + assert_eq!( + ContractError::PaymentError(cw_utils::PaymentError::NoFunds {}), + err.downcast().unwrap() + ); + + // Fails if correct denom but not enough amount. + // Fails if no funds sent. + let err = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(Coin { + denom: "juno".into(), + amount: 999u128.into(), + }), + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::new(1_000) + }, + err.downcast().unwrap() + ); + + // Fails if enough amount but incorrect denom. + let err = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(Coin { + denom: "wynd".into(), + amount: 1_000u128.into(), + }), + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositType {}, + err.downcast().unwrap() + ); + + // Valid submission. + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(Coin { + denom: "juno".into(), + amount: 1_000u128.into(), + }), + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: mock.sender.clone(), + name: "DAOers".to_owned(), + url: "https://daodao.zone".to_owned(), + address: recipient.clone(), + }, + adapter + .query(&AdapterQueryMsg::Submission { + address: recipient.to_string() + }) + .unwrap() + ) +} + +#[test] +fn create_receive_required_deposit() { + let mock = MockBech32::new("mock"); + let cw20 = cw20_helper(mock.clone()); + let bad_cw20 = cw20_helper(mock.clone()); + let cw20_addr = cw20.address().unwrap(); + let bad_cw20_addr = bad_cw20.address().unwrap(); + println!("good cw20: {:#?}", cw20_addr); + println!("bad cw20: {:#?}", bad_cw20_addr); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Cw20(cw20_addr.to_string()), + amount: 1_000u128.into(), + }), + ); + + let recipient = mock.sender_addr().to_string(); + + let binary_msg = to_json_binary(&ReceiveMsg::CreateSubmission { + name: "DAOers".into(), + url: "https://daodao.zone".into(), + address: recipient.clone(), + }) + .unwrap(); + // Fails by sending wrong cw20. + let err = adapter + .call_as(&Addr::unchecked( + "mock1mzdhwvvh22wrt07w59wxyd58822qavwkx5lcej7aqfkpqqlhaqfsetqc4t", + )) + .execute( + &ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { + sender: recipient.to_string(), + amount: Uint128::from(1_000u128), + msg: binary_msg.clone(), + }), + None, + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositType {}, + err.downcast().unwrap(), + ); + + // Fails by sending less tokens than required. + let err = adapter + .call_as(&cw20.address().unwrap()) + .execute( + &ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { + sender: recipient.to_string(), + amount: Uint128::from(999u128), + msg: binary_msg.clone(), + }), + None, + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::new(1_000) + }, + err.downcast().unwrap() + ); + + // Valid submission. + adapter + .call_as(&cw20.address().unwrap()) + .execute( + &ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { + sender: recipient.to_string(), + amount: Uint128::from(1_000u128), + msg: binary_msg, + }), + None, + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: mock.sender.clone(), + name: "DAOers".to_owned(), + url: "https://daodao.zone".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + adapter + .query(&AdapterQueryMsg::Submission { + address: recipient.to_string() + }) + .unwrap() + ); + + assert_eq!( + 2, + adapter + .query::(&AdapterQueryMsg::AllSubmissions {}) + .unwrap() + .submissions + .len() + ) +} + +#[test] +fn return_deposits_no_required_deposit() { + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter(mock.clone(), None); + + let err = adapter + .execute(&ExecuteMsg::ReturnDeposits {}, None) + .unwrap_err(); + + assert_eq!(ContractError::NoDepositToRefund {}, err.downcast().unwrap()) +} + +#[test] +fn return_deposits_no_admin() { + let mock = MockBech32::new("mock"); + let bad_addr = mock.addr_make("einstien"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); + + let err = adapter + .call_as(&bad_addr) + .execute(&ExecuteMsg::ReturnDeposits {}, None) + .unwrap_err(); + + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()) +} + +#[test] +fn return_deposits_required_native_deposit() { + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); + mock.add_balance(&mock.sender, vec![coin(1_000u128, "juno")]) + .unwrap(); + let recipient = mock.addr_make("recipient"); + + // Valid submission. + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); + + assert_eq!( + mock.query_balance(&mock.sender.clone(), "juno").unwrap(), + Uint128::zero() + ); + assert_eq!( + mock.query_balance(&recipient, "juno").unwrap(), + Uint128::zero() + ); + assert_eq!( + mock.query_balance(&adapter.address().unwrap(), "juno") + .unwrap(), + Uint128::from(1000u128) + ); + + adapter + .execute(&ExecuteMsg::ReturnDeposits {}, None) + .unwrap(); + assert_eq!( + mock.query_balance(&mock.sender.clone(), "juno").unwrap(), + Uint128::from(1000u128) + ); + assert_eq!( + mock.query_balance(&recipient, "juno").unwrap(), + Uint128::zero() + ); + assert_eq!( + mock.query_balance(&adapter.address().unwrap(), "juno") + .unwrap(), + Uint128::zero() + ); +} + +#[test] +fn return_deposits_required_native_deposit_multiple_deposits() { + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); + + let recipient = mock.addr_make("recipient"); + let einstien = mock + .addr_make_with_balance("einstien", vec![coin(1_000u128, "juno")]) + .unwrap(); + mock.add_balance(&mock.sender, vec![coin(1_000u128, "juno")]) + .unwrap(); + // Valid submission. + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); + // Valid submission. + native_submission_helper( + adapter.clone(), + einstien.clone(), + einstien.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); + + adapter.return_deposits().unwrap(); + assert_eq!( + mock.query_balance(&mock.sender.clone(), "juno").unwrap(), + Uint128::from(1000u128) + ); + assert_eq!( + mock.query_balance(&einstien, "juno").unwrap(), + Uint128::from(1000u128) + ); + assert_eq!( + mock.query_balance(&recipient, "juno").unwrap(), + Uint128::zero() + ); + assert_eq!( + mock.query_balance(&adapter.address().unwrap(), "juno") + .unwrap(), + Uint128::zero() + ); +} + +#[test] +fn return_deposits_required_cw20_deposit() { + let mock = MockBech32::new("mock"); + let cw20 = cw20_helper(mock.clone()); + let recipient = mock.addr_make("recipient"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Cw20(cw20.addr_str().unwrap()), + amount: 1_000u128.into(), + }), + ); + let binary_msg = to_json_binary(&ReceiveMsg::CreateSubmission { + name: "DAOers".into(), + url: "https://daodao.zone".into(), + address: recipient.to_string(), + }) + .unwrap(); + + // Valid submission. + cw20.send(1_000u128.into(), adapter.addr_str().unwrap(), binary_msg) + .unwrap(); + + assert_eq!( + cw20.balance(mock.sender.to_string()).unwrap().balance, + Uint128::from(999_000u128) + ); + assert_eq!( + cw20.balance(recipient.to_string()).unwrap().balance, + Uint128::zero() + ); + assert_eq!( + cw20.balance(adapter.address().unwrap().to_string()) + .unwrap() + .balance, + Uint128::from(1_000u128), + ); + + adapter.return_deposits().unwrap(); + + assert_eq!( + cw20.balance(mock.sender.to_string()).unwrap().balance, + Uint128::from(1_000_000u128), + ); + // Tokens are sent back to submission sender, not recipient. + assert_eq!( + cw20.balance(recipient.to_string()).unwrap().balance, + Uint128::zero(), + ); + assert_eq!( + cw20.balance(adapter.address().unwrap().to_string()) + .unwrap() + .balance, + Uint128::zero(), + ); +} + +#[test] +fn sample_gauge_msgs_cw20() { + let mock = MockBech32::new("mock"); + let addr_1 = mock.addr_make("addr1"); + let addr_2 = mock.addr_make("addr2"); + let addr_3 = mock.addr_make("addr3"); + let reward = Uint128::new(1_000_000); + let (adapter, cw20) = setup_cw20_reward_gauge_adapter(mock.clone(), None); + + adapter + .create_submission(addr_1.to_string(), "name".into(), "https://test.url".into()) + .unwrap(); + adapter + .create_submission(addr_2.to_string(), "name".into(), "https://test.url".into()) + .unwrap(); + + let selected = vec![ + (addr_1.to_string(), Decimal::percent(41)), + (addr_2.to_string(), Decimal::percent(33)), + (addr_3.to_string(), Decimal::percent(26)), + ]; + + let res: crate::msg::SampleGaugeMsgsResponse = + adapter.sample_gauge_msgs(selected.clone()).unwrap(); + assert_eq!(res.execute.len(), 3); + assert_eq!( + res.execute, + [ + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: cw20.addr_str().unwrap(), + msg: to_json_binary(&AbsCw20ExecuteMsg::Transfer { + recipient: addr_1.to_string(), + amount: reward * Decimal::percent(41) + }) + .unwrap(), + funds: vec![] + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: cw20.addr_str().unwrap(), + msg: to_json_binary(&AbsCw20ExecuteMsg::Transfer { + recipient: addr_2.to_string(), + amount: reward * Decimal::percent(33) + }) + .unwrap(), + funds: vec![] + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: cw20.addr_str().unwrap(), + msg: to_json_binary(&AbsCw20ExecuteMsg::Transfer { + recipient: addr_3.to_string(), + amount: reward * Decimal::percent(26) + }) + .unwrap(), + funds: vec![] + }), + ] + ); +} diff --git a/contracts/gauges/gauge-adapter/src/multitest/suite.rs b/contracts/gauges/gauge-adapter/src/multitest/suite.rs new file mode 100644 index 000000000..a667097fd --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/multitest/suite.rs @@ -0,0 +1,116 @@ +use abstract_cw_plus_interface::cw20_base::Cw20Base; +use cosmwasm_std::{Addr, Coin, Uint128}; +use cw_orch::{interface, mock::cw_multi_test::AppResponse, prelude::*}; +use cw_orch_core::CwEnvError; + +use abstract_cw20::{Cw20Coin as AbsCw20Coin, MinterResponse}; + +use crate::{ + contract::{execute, instantiate, migrate, query}, + msg::{AdapterQueryMsg as QueryMsg, AssetUnchecked, ExecuteMsg, InstantiateMsg, MigrateMsg}, +}; + +// Store the marketing gauge adapter contract and returns the code id. +#[interface(InstantiateMsg, ExecuteMsg, QueryMsg, MigrateMsg)] +pub struct GaugeAdapter; + +impl Uploadable for GaugeAdapter { + /// Return the path to the wasm file corresponding to the contract + fn wasm(_chain: &ChainInfoOwned) -> WasmPath { + artifacts_dir_from_workspace!() + .find_wasm_path("gauge_adapter") + .unwrap() + } + /// Returns a CosmWasm contract wrapper + fn wrapper() -> Box> { + Box::new(ContractWrapper::new_with_empty(execute, instantiate, query).with_migrate(migrate)) + } +} + +pub fn setup_gauge_adapter( + mock: MockBech32, + required_deposit: Option, +) -> GaugeAdapter { + let adapter = GaugeAdapter::new("gauge_adapter", mock.clone()); + adapter.upload().unwrap(); + + let instantiate = InstantiateMsg { + admin: mock.sender_addr().to_string(), + required_deposit, + reward: AssetUnchecked::new_native("juno", 1_000_000), + community_pool: mock.addr_make("community_pool").to_string(), + }; + adapter.instantiate(&instantiate, None, None).unwrap(); + adapter +} + +pub fn setup_cw20_reward_gauge_adapter( + mock: MockBech32, + required_deposit: Option, +) -> (GaugeAdapter, Cw20Base) { + let adapter = GaugeAdapter::new("gauge_adapter", mock.clone()); + adapter.upload().unwrap(); + let cw20 = cw20_helper(mock.clone()); + + let instantiate = InstantiateMsg { + admin: mock.sender_addr().to_string(), + required_deposit, + reward: AssetUnchecked::new_cw20(&cw20.addr_str().unwrap(), 1_000_000), + community_pool: mock.addr_make("community_pool").to_string(), + }; + adapter.instantiate(&instantiate, None, None).unwrap(); + (adapter, cw20) +} + +// +pub fn native_submission_helper( + adapter: GaugeAdapter, + sender: Addr, + recipient: Addr, + native_tokens: Option, +) -> Result { + if let Some(assets) = native_tokens.clone() { + adapter.call_as(&sender).execute( + &crate::msg::ExecuteMsg::CreateSubmission { + name: "DAOers".to_string(), + url: "https://daodao.zone".to_string(), + address: recipient.to_string(), + }, + Some(&[assets]), + ) + } else { + adapter.call_as(&sender).execute( + &crate::msg::ExecuteMsg::CreateSubmission { + name: "DAOers".to_string(), + url: "https://daodao.zone".to_string(), + address: recipient.to_string(), + }, + None, + ) + } +} + +pub fn cw20_helper(mock: MockBech32) -> Cw20Base { + let cw20 = Cw20Base::new("cw20", mock.clone()); + cw20.upload().unwrap(); + init_cw20(cw20.clone(), mock.sender.to_string()); + cw20 +} + +pub fn init_cw20(cw20: Cw20Base, minter: String) -> String { + let init_msg = abstract_cw20_base::msg::InstantiateMsg { + name: "test".to_string(), + symbol: "TEST".to_string(), + decimals: 6u8, + initial_balances: vec![AbsCw20Coin { + address: minter.clone(), + amount: Uint128::from(1_000_000u128), + }], + mint: Some(MinterResponse { minter, cap: None }), + marketing: None, + }; + cw20.instantiate(&init_msg, None, None).unwrap(); + let addr = cw20.address().unwrap(); + println!("correct cw20 addr: {:#?}", addr.clone()); + addr.to_string() +} diff --git a/contracts/gauges/gauge-adapter/src/state.rs b/contracts/gauges/gauge-adapter/src/state.rs new file mode 100644 index 000000000..68bd19d12 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/state.rs @@ -0,0 +1,34 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_denom::CheckedDenom; +use cw_storage_plus::{Item, Map}; + +#[cw_serde] +pub struct Config { + /// Address that is allowed to return deposits. + pub admin: Addr, + /// Deposit required for valid submission. + pub required_deposit: Option, + /// Address of contract where each deposit is transferred. + pub community_pool: Addr, + /// Total reward amount. + pub reward: Asset, +} + +pub const CONFIG: Item = Item::new("config"); + +#[cw_serde] +pub struct Asset { + pub denom: CheckedDenom, + pub amount: Uint128, +} + +#[cw_serde] +pub struct Submission { + pub sender: Addr, + pub name: String, + pub url: String, +} + +// All submissions mapped by fund destination address. +pub const SUBMISSIONS: Map = Map::new("submissions"); diff --git a/contracts/gauges/gauge/.cargo/config b/contracts/gauges/gauge/.cargo/config new file mode 100644 index 000000000..af5698e58 --- /dev/null +++ b/contracts/gauges/gauge/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/gauges/gauge/Cargo.toml b/contracts/gauges/gauge/Cargo.toml new file mode 100644 index 000000000..6cbc8a550 --- /dev/null +++ b/contracts/gauges/gauge/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "gauge-orchestrator" +version = { workspace = true } +authors = ["Cosmorama "] +edition = { workspace = true } + +# This repo was fork from https://github.com/wynddao/wynddao and does not use +# the default DAO DAO license, git history has been retained +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +dao-interface = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +cw4 = { workspace = true } +dao-hooks = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +dao-dao-core = { workspace = true } +cw-multi-test = { workspace = true } +dao-proposal-single = { workspace = true } +cw4-group = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting = { workspace = true } diff --git a/contracts/gauges/gauge/README.md b/contracts/gauges/gauge/README.md new file mode 100644 index 000000000..ef87daf0c --- /dev/null +++ b/contracts/gauges/gauge/README.md @@ -0,0 +1,75 @@ +# Gauge Orchestrator Contract + +There are many places where we want something like a [gauge contract](https://resources.curve.fi/reward-gauges/gauge-weights), +when we need to select a weighted group out of a larger group of options. + +## Orchestrator + +To work properly, the gauge must be informed every time that the voting power of a member changes. +It does so by listening to "update hooks" on the underlying staking contract and if an address's +voting power changes, updating their vote weight in the gauge, and the tally for the option they +had voted for (if any). + +Every contract call has some overhead, which is silently added to the basic staking action. + +If we have 5 gauges in a DAO, we would likely have a minimum of 5 x 65k or 325k gas per staking action, just to update gauges. This is a lot of overhead, and we want to avoid it. + +To do so, we make one "Gauge Orchestrator", which can manage many different gauges. They all have the +same voting logic and rules to update when the voting power changes. The Orchestrator is the only +contract that must be called by the staking contract, and doing a few writes for each gauge is a +lot cheaper gas-wise than calling a separate contract. + +The Orchestrator has an "owner" (the DAO) which is responsible for adding new gauges here, +and eventually stopping them if we don't need them anymore (to avoid extra writes). + +## Gauge Functionality + +A gauge is initialized with a set of options. Anyone with voting power may vote for any option at any time, +which is recorded, and also updates the tally. If they re-vote, it checks their last vote to reduce power on +that before adding to the new one. When an "update hook" is triggered, it updates the voting power of that user's vote, while maintaining the same option. Either increasing or decreasing the tally for the given option as appropriate. + +Every epoch (eg 1/week), the current tally of the gauge is sampled, and some cut-off applies +(top 20, min 0.5% of votes, etc). The resulting set is the "selected set" and the options along with +their relative vote counts (normalized to 1.0 = total votes within this set) is used to initiate some +action (eg. distribute reward tokens). + +## Extensibility + +We will be using one Orchestrator for many different gauges that update many different contracts. +To make it more extensible, we define option as an arbitrary string that makes sense to that contract. + +We also store the integration logic in an external contract, called a `GaugeAdapter` that must provide +3 queries to the Orchestrator: + +* Provide set of all options: maybe expensive, iterate over all and return them. This is used for initialization. +* Check an option: Allow anyone to propose one, and this confirms if it is valid (eg is this a valid address + of a registered AMM pool?) +* Create update messages: Accepts "selected set" as argument, returns `Vec` to be executed by the + gauge contract / DAO. + + +We will have a mock implementation of an Adapter for testing. + +## Example Use + +When the DAO wants to add another gauge, it first uploads the code for generating eg. AMM reward messages, +and instantiates a properly configured Adapter. + +Then, it votes to create a new Gauge that uses this adapter. Upon creating the gauge, it will query the adapter +for the current set of options to initialize state. + +After one epoch has passed, anyone can trigger `Execute` on this gauge ID, and the Orchestrator will +apply the logic to determine the "selected set". It will then query the adapter for the messages +needed to convert that selection into the appropriate action, and it will send those to the +DAO DAO core module to be executed. + +## Storage + +Every gauge that is created is given a new auto-incrementing ID. + +All non-global state in the contract (only owner and voting power contract) is indexed +first by the gauge and then by the other key (eg. voter address for Votes, option for tallied power, etc) + +We do not know how many gauges will be there a priori and this composite index allows us to +be flexible. Not the use of `.prefix()` and `.sub_prefix()` in `state.rs` tests to efficiently +focus on the relevant data for one gauge. diff --git a/contracts/gauges/gauge/src/bin/schema.rs b/contracts/gauges/gauge/src/bin/schema.rs new file mode 100644 index 000000000..282b761ee --- /dev/null +++ b/contracts/gauges/gauge/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; + +use gauge_orchestrator::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/gauges/gauge/src/contract.rs b/contracts/gauges/gauge/src/contract.rs new file mode 100644 index 000000000..20ca4faec --- /dev/null +++ b/contracts/gauges/gauge/src/contract.rs @@ -0,0 +1,1039 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + ensure, to_json_binary, Addr, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Order, + QueryRequest, Response, StdError, StdResult, Uint128, WasmMsg, WasmQuery, +}; +use cw2::{ensure_from_older_version, set_contract_version}; +use cw_storage_plus::Bound; +use dao_interface::{ + msg::ExecuteMsg as DaoExecuteMsg, + voting::{Query as DaoQuery, VotingPowerAtHeightResponse}, +}; + +use crate::msg::{ + AdapterQueryMsg, AllOptionsResponse, CheckOptionResponse, ExecuteMsg, GaugeConfig, + GaugeResponse, InstantiateMsg, ListGaugesResponse, ListOptionsResponse, ListVotesResponse, + MigrateMsg, QueryMsg, SampleGaugeMsgsResponse, SelectedSetResponse, +}; +use crate::state::{ + fetch_last_id, update_tally, votes, Config, Gauge, GaugeId, CONFIG, GAUGES, OPTION_BY_POINTS, + TALLY, TOTAL_CAST, +}; +use crate::{error::ContractError, state::Reset}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:gauge"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let voting_powers = deps.api.addr_validate(&msg.voting_powers)?; + let hook_caller = deps.api.addr_validate(&msg.hook_caller)?; + let owner = deps.api.addr_validate(&msg.owner)?; + let config = Config { + voting_powers, + hook_caller, + owner, + dao_core: info.sender, + }; + CONFIG.save(deps.storage, &config)?; + + for gauge in msg.gauges.unwrap_or_default() { + execute::attach_gauge(deps.branch(), env.clone(), gauge)?; + } + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("owner", &msg.owner) + .add_attribute("voting_powers", &msg.voting_powers)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::StakeChangeHook(msg) => execute::stake_changed(deps, info, msg), + ExecuteMsg::NftStakeChangeHook(msg) => execute::nft_stake_changed(deps, info, msg), + ExecuteMsg::MemberChangedHook(hook_msg) => { + execute::member_changed(deps, info.sender, hook_msg.diffs) + } + ExecuteMsg::CreateGauge(options) => execute::create_gauge(deps, env, info.sender, options), + ExecuteMsg::UpdateGauge { + gauge_id, + epoch_size, + min_percent_selected, + max_options_selected, + max_available_percentage, + } => execute::update_gauge( + deps, + info.sender, + gauge_id, + epoch_size, + min_percent_selected, + max_options_selected, + max_available_percentage, + ), + ExecuteMsg::StopGauge { gauge } => execute::stop_gauge(deps, info.sender, gauge), + ExecuteMsg::ResetGauge { gauge, batch_size } => { + execute::reset_gauge(deps, env, gauge, batch_size) + } + ExecuteMsg::AddOption { gauge, option } => { + execute::add_option(deps, info.sender, gauge, option, true) + } + ExecuteMsg::RemoveOption { gauge, option } => { + execute::remove_option(deps, info.sender, gauge, option) + } + ExecuteMsg::PlaceVotes { gauge, votes } => { + execute::place_votes(deps, env, info.sender, gauge, votes) + } + ExecuteMsg::Execute { gauge } => execute::execute(deps, env, gauge), + } +} + +mod execute { + use cw4::MemberDiff; + use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; + + use super::*; + use crate::state::{remove_tally, update_tallies, Reset, Vote}; + use std::collections::HashMap; + + pub fn member_changed( + deps: DepsMut, + sender: Addr, + diffs: Vec, + ) -> Result { + // make sure only hook caller contract can activate this endpoint + if sender != CONFIG.load(deps.storage)?.hook_caller { + return Err(ContractError::Unauthorized {}); + } + + let mut response = Response::new().add_attribute("action", "member_changed_hook"); + let mut gauges = HashMap::new(); + + for diff in diffs { + response = response.add_attribute("member", &diff.key); + let voter = deps.api.addr_validate(&diff.key)?; + + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in + votes().query_votes_by_voter(deps.as_ref(), &voter, None, Some(query::MAX_LIMIT))? + { + // find change of vote powers + let old = Uint128::new(diff.old.unwrap_or_default().into()); + let new = Uint128::new(diff.new.unwrap_or_default().into()); + + // load gauge if not already loaded + let gauge = gauges + .entry(vote.gauge_id) + .or_insert_with(|| GAUGES.load(deps.storage, vote.gauge_id).unwrap()); + + if vote.is_expired(gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // store new vote power for this user + vote.power = new; + votes().save(deps.storage, &voter, vote.gauge_id, &vote)?; + } + } + + Ok(response) + } + + pub fn stake_changed( + deps: DepsMut, + info: MessageInfo, + msg: StakeChangedHookMsg, + ) -> Result { + // make sure only hook caller contract can activate this endpoint + if info.sender != CONFIG.load(deps.storage)?.hook_caller { + return Err(ContractError::Unauthorized {}); + } + + match msg { + StakeChangedHookMsg::Stake { addr, amount } => { + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in votes().query_votes_by_voter( + deps.as_ref(), + &addr, + None, + Some(query::MAX_LIMIT), + )? { + let gauge = GAUGES.load(deps.storage, vote.gauge_id)?; + + let old = vote.power; + + // Voting power increases with staking amount + let new = vote.power + amount; + + if vote.is_expired(&gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // Update and store new vote power for this user + vote.power = new; + votes().save(deps.storage, &addr, vote.gauge_id, &vote)?; + } + + Ok(Response::new()) + } + StakeChangedHookMsg::Unstake { addr, amount } => { + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in votes().query_votes_by_voter( + deps.as_ref(), + &addr, + None, + Some(query::MAX_LIMIT), + )? { + let gauge = GAUGES.load(deps.storage, vote.gauge_id)?; + + let old = vote.power; + + // Decrease voting power by unstaked amount + let new = vote.power - amount; + + if vote.is_expired(&gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // Update and store new vote power for this user + vote.power = new; + votes().save(deps.storage, &addr, vote.gauge_id, &vote)?; + } + + Ok(Response::new()) + } + } + } + + pub fn nft_stake_changed( + deps: DepsMut, + info: MessageInfo, + msg: NftStakeChangedHookMsg, + ) -> Result { + // make sure only hook caller contract can activate this endpoint + if info.sender != CONFIG.load(deps.storage)?.hook_caller { + return Err(ContractError::Unauthorized {}); + } + + match msg { + NftStakeChangedHookMsg::Stake { addr, token_id: _ } => { + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in votes().query_votes_by_voter( + deps.as_ref(), + &addr, + None, + Some(query::MAX_LIMIT), + )? { + let gauge = GAUGES.load(deps.storage, vote.gauge_id)?; + + let old = vote.power; + // Voting power increases by one (only one token_id staked at a time) + let new = vote.power + Uint128::one(); + + if vote.is_expired(&gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // Update and store new vote power for this user + vote.power = new; + votes().save(deps.storage, &addr, vote.gauge_id, &vote)?; + } + + Ok(Response::new()) + } + NftStakeChangedHookMsg::Unstake { addr, token_ids } => { + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in votes().query_votes_by_voter( + deps.as_ref(), + &addr, + None, + Some(query::MAX_LIMIT), + )? { + let gauge = GAUGES.load(deps.storage, vote.gauge_id)?; + + let old = vote.power; + + // Decrease voting power by number of token_ids + let amount: u128 = token_ids.len().try_into().unwrap(); + let new = vote.power - Uint128::new(amount); + + if vote.is_expired(&gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // Update and store new vote power for this user + vote.power = new; + votes().save(deps.storage, &addr, vote.gauge_id, &vote)?; + } + + Ok(Response::new()) + } + } + } + + pub fn create_gauge( + deps: DepsMut, + env: Env, + sender: Addr, + options: GaugeConfig, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let adapter = attach_gauge(deps, env, options)?; + + Ok(Response::new() + .add_attribute("action", "create_gauge") + .add_attribute("adapter", adapter)) + } + + pub fn attach_gauge( + mut deps: DepsMut, + env: Env, + GaugeConfig { + title, + adapter, + epoch_size, + min_percent_selected, + max_options_selected, + max_available_percentage, + reset_epoch, + }: GaugeConfig, + ) -> Result { + let adapter = deps.api.addr_validate(&adapter)?; + // gauge parameter validation + ensure!(epoch_size > 60u64, ContractError::EpochSizeTooShort {}); + if let Some(min_percent_selected) = min_percent_selected { + ensure!( + min_percent_selected < Decimal::one(), + ContractError::MinPercentSelectedTooBig {} + ); + } + ensure!( + max_options_selected > 0, + ContractError::MaxOptionsSelectedTooSmall {} + ); + let gauge = Gauge { + title, + adapter: adapter.clone(), + epoch: epoch_size, + min_percent_selected, + max_options_selected, + max_available_percentage, + is_stopped: false, + next_epoch: env.block.time.seconds() + epoch_size, + last_executed_set: None, + reset: reset_epoch.map(|r| Reset { + last: None, + reset_each: r, + next: env.block.time.plus_seconds(r).seconds(), + }), + }; + let last_id: GaugeId = fetch_last_id(deps.storage)?; + GAUGES.save(deps.storage, last_id, &gauge)?; + + // fetch adapter options + let adapter_options: AllOptionsResponse = + deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: adapter.to_string(), + msg: to_json_binary(&AdapterQueryMsg::AllOptions {})?, + }))?; + adapter_options.options.into_iter().try_for_each(|option| { + execute::add_option(deps.branch(), adapter.clone(), last_id, option, false)?; + Ok::<_, ContractError>(()) + })?; + + Ok(adapter) + } + + pub fn update_gauge( + deps: DepsMut, + sender: Addr, + gauge_id: u64, + epoch_size: Option, + min_percent_selected: Option, + max_options_selected: Option, + max_available_percentage: Option, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; + if let Some(epoch_size) = epoch_size { + ensure!(epoch_size > 60u64, ContractError::EpochSizeTooShort {}); + gauge.epoch = epoch_size; + } + if let Some(min_percent_selected) = min_percent_selected { + if min_percent_selected.is_zero() { + gauge.min_percent_selected = None + } else { + ensure!( + min_percent_selected < Decimal::one(), + ContractError::MinPercentSelectedTooBig {} + ); + gauge.min_percent_selected = Some(min_percent_selected) + }; + } + if let Some(max_options_selected) = max_options_selected { + ensure!( + max_options_selected > 0, + ContractError::MaxOptionsSelectedTooSmall {} + ); + gauge.max_options_selected = max_options_selected; + } + if let Some(max_available_percentage) = max_available_percentage { + if max_available_percentage.is_zero() { + gauge.max_available_percentage = None + } else { + ensure!( + max_available_percentage < Decimal::one(), + ContractError::MaxAvailablePercentTooBig {} + ); + gauge.max_available_percentage = Some(max_available_percentage) + }; + } + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new().add_attribute("action", "update_gauge")) + } + + pub fn stop_gauge( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let gauge = GAUGES.load(deps.storage, gauge_id)?; + let gauge = Gauge { + is_stopped: true, + ..gauge + }; + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new() + .add_attribute("action", "stop_gauge") + .add_attribute("gauge_id", gauge_id.to_string())) + } + + pub fn remove_option( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + option: String, + ) -> Result { + // check if such option even exists + if !TALLY.has(deps.as_ref().storage, (gauge_id, &option)) { + return Err(ContractError::OptionDoesNotExists { option, gauge_id }); + }; + + // only owner can remove option for now + if sender != CONFIG.load(deps.storage)?.owner { + return Err(ContractError::Unauthorized {}); + } + + remove_tally(deps.storage, gauge_id, &option)?; + + Ok(Response::new() + .add_attribute("action", "remove_option") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()) + .add_attribute("option", option)) + } + + pub fn reset_gauge( + deps: DepsMut, + env: Env, + gauge_id: GaugeId, + batch_size: u32, + ) -> Result { + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; + match gauge.reset { + Some(ref mut reset) if reset.next <= env.block.time.seconds() => { + reset.last = Some(reset.next); + + // remove all options from the gauge + let keys = OPTION_BY_POINTS + .sub_prefix(gauge_id) + .keys(deps.storage, None, None, Order::Ascending) + .take(batch_size as usize) + .collect::>>()?; + for (points, option) in &keys { + OPTION_BY_POINTS.remove(deps.storage, (gauge_id, *points, option)); + OPTION_BY_POINTS.save(deps.storage, (gauge_id, 0, option), &1)?; + TALLY.save(deps.storage, (gauge_id, option), &0)?; + } + + // if this is the last batch, update the reset epoch + if (keys.len() as u32) < batch_size { + // removing total cast only once at the end to save gas + TOTAL_CAST.save(deps.storage, gauge_id, &0)?; + reset.next += reset.reset_each; + } + } + Some(_) => { + return Err(ContractError::ResetEpochNotPassed {}); + } + None => { + return Err(ContractError::Unauthorized {}); + } + } + + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new() + .add_attribute("action", "reset_gauge") + .add_attribute("gauge_id", gauge_id.to_string())) + } + + // TODO this doesn't seem very safe... double check permissions here + // Why is check option optional? + pub fn add_option( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + option: String, + // must be true if option is added by execute message + check_option: bool, + ) -> Result { + // check is such option already exists + if TALLY.has(deps.as_ref().storage, (gauge_id, &option)) { + return Err(ContractError::OptionAlreadyExists { option, gauge_id }); + }; + + // only options added from gauge creation level should not be validated and can + // have 0 points as assigned voting power. + if check_option { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + // query gauge adapter if it is valid + let adapter_option: CheckOptionResponse = deps + .querier + .query_wasm_smart( + gauge.adapter, + &AdapterQueryMsg::CheckOption { + option: option.clone(), + }, + ) + .map_err(|_| ContractError::OptionInvalidByAdapter { + option: option.clone(), + gauge_id, + })?; + if !adapter_option.valid { + return Err(ContractError::OptionInvalidByAdapter { option, gauge_id }); + } + // If it is a user adding option, query him for voting power in order to prevent + // spam from nonvoting users + let voting_power = deps + .querier + .query::(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: CONFIG.load(deps.storage)?.voting_powers.to_string(), + msg: to_json_binary(&DaoQuery::VotingPowerAtHeight { + address: sender.to_string(), + height: None, + })?, + }))? + .power; + if voting_power.is_zero() { + return Err(ContractError::NoVotingPower(sender.to_string())); + } + } + + update_tally(deps.storage, gauge_id, &option, 0u128, 0u128)?; + + Ok(Response::new() + .add_attribute("action", "add_option") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()) + .add_attribute("option", option)) + } + + pub fn place_votes( + deps: DepsMut, + env: Env, + sender: Addr, + gauge_id: GaugeId, + new_votes: Option>, + ) -> Result { + let gauge = match GAUGES.may_load(deps.storage, gauge_id)? { + Some(gauge) => gauge, + None => return Err(ContractError::GaugeMissing(gauge_id)), + }; + + if gauge.is_resetting() { + return Err(ContractError::GaugeResetting(gauge_id)); + } + + // make sure sums work out + let new_votes = new_votes.unwrap_or_default(); + let total_weight = new_votes.iter().map(|v| v.weight).sum(); + if total_weight > Decimal::one() { + return Err(ContractError::TooMuchVotingWeight(total_weight)); + } + + // load voter power from voting powers contract (DAO) + let voting_power = deps + .querier + .query::(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: CONFIG.load(deps.storage)?.voting_powers.to_string(), + msg: to_json_binary(&DaoQuery::VotingPowerAtHeight { + address: sender.to_string(), + height: None, + })?, + }))? + .power; + if voting_power.is_zero() { + return Err(ContractError::NoVotingPower(sender.to_string())); + } + + let mut previous_vote = votes().may_load(deps.storage, &sender, gauge_id)?; + if let Some(v) = &previous_vote { + if v.is_expired(&gauge) { + previous_vote = None; + } + } + if previous_vote.is_none() && new_votes.is_empty() { + return Err(ContractError::CannotRemoveNonexistingVote {}); + } + + // first, calculate a diff between new_vote and previous_vote (option -> (old, new)) + let previous_vote = previous_vote.unwrap_or_default(); + let power = previous_vote.power; + let mut diff: HashMap<&str, (u128, u128)> = previous_vote + .votes + .iter() + .map(|v| (v.option.as_str(), ((power * v.weight).u128(), 0u128))) + .collect(); + for v in new_votes.iter() { + let new = (voting_power * v.weight).u128(); + let add = match diff.remove(v.option.as_str()) { + Some((old, _)) => (old, new), + None => (0, new), + }; + diff.insert(&v.option, add); + } + + // second, test any new options are valid, + // only for those voted for first time (others have already been checked) + for new_opt in diff + .iter() + .filter(|(_, (old, _))| *old == 0) + .map(|(&k, _)| k) + { + if !TALLY.has(deps.storage, (gauge_id, new_opt)) { + return Err(ContractError::OptionDoesNotExists { + option: new_opt.to_string(), + gauge_id, + }); + } + } + + // third, update tally based on diff + let updates: Vec<(&str, u128, u128)> = diff + .iter() + .map(|(&k, (old, new))| (k, *old, *new)) + .collect(); + update_tallies(deps.storage, gauge_id, updates)?; + + // finally, update the votes for this user + if new_votes.is_empty() { + // completely remove sender's votes + votes().remove_votes(deps.storage, &sender, gauge_id)?; + } else { + // store sender's new votes (overwriting old votes) + votes().set_votes( + deps.storage, + &env, + &sender, + gauge_id, + new_votes, + voting_power, + )?; + } + + let response = Response::new() + .add_attribute("action", "place_vote") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()); + Ok(response) + } + + pub fn execute(deps: DepsMut, env: Env, gauge_id: u64) -> Result { + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; + + if gauge.is_stopped { + return Err(ContractError::GaugeStopped(gauge_id)); + } + if gauge.is_resetting() { + return Err(ContractError::GaugeResetting(gauge_id)); + } + + let current_epoch = env.block.time.seconds(); + if current_epoch < gauge.next_epoch { + return Err(ContractError::EpochNotReached { + gauge_id, + current_epoch, + next_epoch: gauge.next_epoch, + }); + } + gauge.next_epoch = env.block.time.plus_seconds(gauge.epoch).seconds(); + + // this set contains tuple (option, total_voted_power) + // for adapter query, this needs to be transformed into (option, voted_weight) + let selected_set_with_powers = query::selected_set(deps.as_ref(), gauge_id)?.votes; + let selected_powers_sum = selected_set_with_powers + .iter() + .map(|(_, power)| power.u128()) + .sum::(); + + // save the selected options and their powers for the frontend to display + gauge.last_executed_set = Some(selected_set_with_powers.clone()); + + // calculate "local" ratios of voted options per total power of all selected options + let selected = selected_set_with_powers + .into_iter() + .map(|(option, power)| Ok((option, Decimal::from_ratio(power, selected_powers_sum)))) + .collect::>>()?; + + // query gauge adapter for execute messages for DAO + let execute_messages: SampleGaugeMsgsResponse = deps.querier.query_wasm_smart( + gauge.adapter.clone(), + &AdapterQueryMsg::SampleGaugeMsgs { selected }, + )?; + + let config = CONFIG.load(deps.storage)?; + let execute_msg = WasmMsg::Execute { + contract_addr: config.dao_core.to_string(), + msg: to_json_binary(&DaoExecuteMsg::ExecuteProposalHook { + msgs: execute_messages.execute, + })?, + funds: vec![], + }; + + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new() + .add_attribute("action", "execute_tally") + .add_message(execute_msg)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Info {} => Ok(to_json_binary(&query::info(deps)?)?), + QueryMsg::Gauge { id } => Ok(to_json_binary(&query::gauge(deps, id)?)?), + QueryMsg::ListGauges { start_after, limit } => Ok(to_json_binary(&query::list_gauges( + deps, + start_after, + limit, + )?)?), + QueryMsg::Vote { gauge, voter } => Ok(to_json_binary(&query::vote(deps, gauge, voter)?)?), + QueryMsg::ListVotes { + gauge, + start_after, + limit, + } => Ok(to_json_binary(&query::list_votes( + deps, + gauge, + start_after, + limit, + )?)?), + QueryMsg::ListOptions { + gauge, + start_after, + limit, + } => Ok(to_json_binary(&query::list_options( + deps, + gauge, + start_after, + limit, + )?)?), + QueryMsg::SelectedSet { gauge } => Ok(to_json_binary(&query::selected_set(deps, gauge)?)?), + QueryMsg::LastExecutedSet { gauge } => { + Ok(to_json_binary(&query::last_executed_set(deps, gauge)?)?) + } + } +} + +mod query { + use super::*; + + use crate::msg::{LastExecutedSetResponse, VoteInfo, VoteResponse}; + use dao_interface::voting::InfoResponse; + + pub fn info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + Ok(InfoResponse { info }) + } + + fn to_gauge_response(gauge_id: GaugeId, gauge: Gauge) -> GaugeResponse { + GaugeResponse { + id: gauge_id, + title: gauge.title, + adapter: gauge.adapter.to_string(), + epoch_size: gauge.epoch, + min_percent_selected: gauge.min_percent_selected, + max_options_selected: gauge.max_options_selected, + max_available_percentage: gauge.max_available_percentage, + is_stopped: gauge.is_stopped, + next_epoch: gauge.next_epoch, + reset: gauge.reset, + } + } + + pub fn gauge(deps: Deps, gauge_id: GaugeId) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + Ok(to_gauge_response(gauge_id, gauge)) + } + + // settings for pagination + pub const MAX_LIMIT: u32 = 100; + pub const DEFAULT_LIMIT: u32 = 30; + + pub fn list_gauges( + deps: Deps, + start_after: Option, + limit: Option, + ) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + + Ok(ListGaugesResponse { + gauges: GAUGES + .range(deps.storage, start, None, Order::Ascending) + .map(|item| { + let (id, gauge) = item?; + Ok(to_gauge_response(id, gauge)) + }) + .take(limit) + .collect::>>()?, + }) + } + + pub fn vote(deps: Deps, gauge_id: u64, voter: String) -> StdResult { + let voter_addr = deps.api.addr_validate(&voter)?; + let gauge = GAUGES.load(deps.storage, gauge_id)?; + + let vote = votes() + .may_load(deps.storage, &voter_addr, gauge_id)? + .filter(|v| !v.is_expired(&gauge)) + .map(|v| VoteInfo { + voter, + votes: v.votes, + cast: v.cast, + }); + Ok(VoteResponse { vote }) + } + + pub fn list_votes( + deps: Deps, + gauge_id: u64, + start_after: Option, + limit: Option, + ) -> StdResult { + Ok(ListVotesResponse { + votes: votes().query_votes_by_gauge(deps, gauge_id, start_after, limit)?, + }) + } + + pub fn list_options( + deps: Deps, + gauge_id: u64, + start_after: Option, + limit: Option, + ) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start_after = start_after.as_ref().map(|s| Bound::exclusive(s.as_str())); + + Ok(ListOptionsResponse { + options: TALLY + .prefix(gauge_id) + .range(deps.storage, start_after, None, Order::Ascending) + .map(|option| { + let (option, power) = option?; + Ok((option, Uint128::new(power))) + }) + .take(limit) + .collect::>>()?, + }) + } + + pub fn selected_set(deps: Deps, gauge_id: u64) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + let total_cast = TOTAL_CAST.load(deps.storage, gauge_id)?; + + if gauge.is_resetting() || total_cast == 0 { + return Ok(SelectedSetResponse { votes: vec![] }); + } + + // This is sorted index, but requires manual filtering - cannot be prefixed + // given our requirements + let votes = OPTION_BY_POINTS + .sub_prefix(gauge_id) + .range(deps.storage, None, None, Order::Descending) + .filter(|o| { + let ((power, _), _) = o.as_ref().unwrap(); + if let Some(min_percent_selected) = gauge.min_percent_selected { + Decimal::from_ratio(*power, total_cast) >= min_percent_selected + } else { + // filter out options without a vote + *power != 0u128 + } + }) + .map(|o| { + let ((power, option), _) = o?; + // If gauge has max_available_percentage set, discard all power + // above that percentage + if let Some(max_available_percentage) = gauge.max_available_percentage { + if Decimal::from_ratio(power, total_cast) > max_available_percentage { + // If power is above available percentage, cut power down to max available + return Ok((option, Uint128::new(total_cast) * max_available_percentage)); + } + } + Ok((option, Uint128::new(power))) + }) + .take(gauge.max_options_selected as usize) + .collect::>>()?; + + Ok(SelectedSetResponse { votes }) + } + + pub fn last_executed_set(deps: Deps, gauge_id: u64) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + Ok(LastExecutedSetResponse { + votes: gauge.last_executed_set, + }) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg) -> Result { + ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + for (gauge_id, config) in msg.gauge_config.unwrap_or_default() { + GAUGES.update(deps.storage, gauge_id, |gauge| -> StdResult<_> { + let mut gauge = gauge.ok_or(StdError::NotFound { + kind: format!("Gauge with id {}", gauge_id), + })?; + if let Some(next_epoch) = config.next_epoch { + if next_epoch < env.block.time.seconds() { + return Err(StdError::GenericErr { + msg: "Next epoch value cannot be earlier then current epoch!".to_owned(), + }); + } + gauge.next_epoch = next_epoch; + } + if let Some(reset_config) = config.reset { + if reset_config.next_reset < env.block.time.seconds() { + return Err(StdError::GenericErr { + msg: "Next reset value cannot be earlier then current epoch!".to_owned(), + }); + } + gauge.reset = Some(Reset { + last: gauge.reset.map(|r| r.last).unwrap_or_default(), + reset_each: reset_config.reset_epoch, + next: reset_config.next_reset, + }); + } + Ok(gauge) + })?; + } + + Ok(Response::new()) +} diff --git a/contracts/gauges/gauge/src/error.rs b/contracts/gauges/gauge/src/error.rs new file mode 100644 index 000000000..c320a4ec7 --- /dev/null +++ b/contracts/gauges/gauge/src/error.rs @@ -0,0 +1,63 @@ +use cosmwasm_std::{Decimal, StdError}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Gauge with ID {0} does not exists")] + GaugeMissing(u64), + + #[error("Voted for {0} times total voting power. Limit 1.0")] + TooMuchVotingWeight(Decimal), + + #[error("User {0} has no voting power")] + NoVotingPower(String), + + #[error("Option {option} already exists for gauge ID {gauge_id}")] + OptionAlreadyExists { option: String, gauge_id: u64 }, + + #[error("Option {option} has been judged as invalid by gauge adapter of gauge ID {gauge_id}")] + OptionInvalidByAdapter { option: String, gauge_id: u64 }, + + #[error("Option {option} has been judged as valid by gauge adapter of gauge ID {gauge_id} and cannot be removed")] + OptionValidByAdapter { option: String, gauge_id: u64 }, + + #[error("Option {option} does not exists for gauge ID {gauge_id}")] + OptionDoesNotExists { option: String, gauge_id: u64 }, + + #[error("Gauge ID {gauge_id} cannot execute because next_epoch is not yet reached: current {current_epoch}, next_epoch: {next_epoch}")] + EpochNotReached { + gauge_id: u64, + current_epoch: u64, + next_epoch: u64, + }, + + #[error("Reset epoch has not passed yet")] + ResetEpochNotPassed {}, + + #[error("Gauge ID {0} cannot execute because it is stopped")] + GaugeStopped(u64), + + #[error("Gauge ID {0} is currently resetting, please try again later")] + GaugeResetting(u64), + + #[error("Trying to remove vote that does not exists")] + CannotRemoveNonexistingVote {}, + + #[error("Epoch size must be bigger then 60 seconds")] + EpochSizeTooShort {}, + + #[error("Minimum percent selected parameter needs to be smaller then 1.0")] + MinPercentSelectedTooBig {}, + + #[error("Maximum options selected parameter needs to be bigger then 0")] + MaxOptionsSelectedTooSmall {}, + + #[error("Maximum percentage available parameter needs to be smaller then 1.0")] + MaxAvailablePercentTooBig {}, +} diff --git a/contracts/gauges/gauge/src/helpers.rs b/contracts/gauges/gauge/src/helpers.rs new file mode 100644 index 000000000..6ffc885df --- /dev/null +++ b/contracts/gauges/gauge/src/helpers.rs @@ -0,0 +1,27 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, StdResult, WasmMsg}; + +use crate::msg::ExecuteMsg; + +/// CwTemplateContract is a wrapper around Addr that provides a lot of helpers +/// for working with this. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct CwTemplateContract(pub Addr); + +impl CwTemplateContract { + pub fn addr(&self) -> Addr { + self.0.clone() + } + + pub fn call>(&self, msg: T) -> StdResult { + let msg = to_json_binary(&msg.into())?; + Ok(WasmMsg::Execute { + contract_addr: self.addr().into(), + msg, + funds: vec![], + } + .into()) + } +} diff --git a/contracts/gauges/gauge/src/lib.rs b/contracts/gauges/gauge/src/lib.rs new file mode 100644 index 000000000..8e1682396 --- /dev/null +++ b/contracts/gauges/gauge/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +mod error; +pub mod helpers; +pub mod msg; +#[cfg(test)] +mod multitest; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/gauges/gauge/src/msg.rs b/contracts/gauges/gauge/src/msg.rs new file mode 100644 index 000000000..3578e32ec --- /dev/null +++ b/contracts/gauges/gauge/src/msg.rs @@ -0,0 +1,263 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Decimal, Uint128}; +use cw4::MemberChangedHookMsg; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; + +use crate::state::{Reset, Vote}; + +type GaugeId = u64; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address of contract to that contains all voting powers (where we query) + pub voting_powers: String, + /// Addres that will call voting power change hooks (often same as voting power contract) + pub hook_caller: String, + /// Address that can add new gauges or stop them + pub owner: String, + /// Allow attaching multiple adaptors during instantiation. + /// Important, as instantiation and CreateGauge both come from DAO proposals + /// and without this argument, you need 2 cycles to create and configure a gauge + pub gauges: Option>, +} + +#[cw_serde] +pub struct GaugeConfig { + /// Name of the gauge (for UI) + pub title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: String, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch_size: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set. + /// If unset, there is no minimum percentage, just the `max_options_selected` limit. + pub min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, + /// If set, the gauge can be reset periodically, every `reset_epoch` seconds. + pub reset_epoch: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Updates gauge voting power in Token DAOs when a user stakes or unstakes + StakeChangeHook(StakeChangedHookMsg), + /// Updates gauge voting power in NFT DAOs when a user stakes or unstakes + NftStakeChangeHook(NftStakeChangedHookMsg), + /// Updates gauge voting power for membership changes + MemberChangedHook(MemberChangedHookMsg), + /// This creates a new Gauge, returns CreateGaugeReply JSON-encoded in the data field. + /// Can only be called by owner + CreateGauge(GaugeConfig), + /// Allows owner to update certain parameters of GaugeConfig. + /// If you want to change next_epoch value, you need to use migration. + UpdateGauge { + gauge_id: u64, + epoch_size: Option, + // Some<0> would set min_percent_selected to None + min_percent_selected: Option, + max_options_selected: Option, + max_available_percentage: Option, + }, + /// Stops a given gauge, meaning it will not execute any more messages, + /// Or receive any more updates on MemberChangedHook. + /// Ideally, this will allow for eventual deletion of all data on that gauge + StopGauge { gauge: u64 }, + /// Resets all votes on a given gauge if it is configured to be periodically reset and the epoch has passed. + /// One call to this will only clear `batch_size` votes to prevent gas exhaustion. Call repeatedly to clear all votes. + ResetGauge { gauge: u64, batch_size: u32 }, + // WISH: make this implicit - call it inside PlaceVote. + // If not, I would just make it invisible to user in UI (smart client adds it if needed) + /// Try to add an option. Error if no such gauge, or option already registered. + /// Otherwise check adapter and error if invalid. + /// Can be called by anyone, not just owner + AddOption { gauge: u64, option: String }, + /// Allows the owner to remove an option. This is useful if the option is no longer valid + /// or if the owner wants to remove all votes from a valid option. + RemoveOption { gauge: u64, option: String }, + /// Place your vote on the gauge. Can be updated anytime + PlaceVotes { + /// Gauge to vote on + gauge: u64, + /// The options to put my vote on, along with proper weights (must sum up to 1.0) + /// "None" means remove existing votes and abstain + votes: Option>, + }, + /// Takes a sample of the current tally and execute the proper messages to make it work + Execute { gauge: u64 }, +} + +#[cw_serde] +pub struct CreateGaugeReply { + /// Id of the gauge that was just created + pub id: u64, +} + +/// Queries the gauge exposes +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(dao_interface::voting::InfoResponse)] + Info {}, + #[returns(GaugeResponse)] + Gauge { id: u64 }, + #[returns(ListGaugesResponse)] + ListGauges { + start_after: Option, + limit: Option, + }, + #[returns(VoteResponse)] + Vote { gauge: u64, voter: String }, + #[returns(ListVotesResponse)] + ListVotes { + gauge: u64, + start_after: Option, + limit: Option, + }, + #[returns(ListOptionsResponse)] + ListOptions { + gauge: u64, + start_after: Option, + limit: Option, + }, + #[returns(SelectedSetResponse)] + SelectedSet { gauge: u64 }, + #[returns(LastExecutedSetResponse)] + LastExecutedSet { gauge: u64 }, +} + +/// Information about one gauge +#[cw_serde] +pub struct GaugeResponse { + pub id: u64, + /// Name of the gauge (for UI) + pub title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: String, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch_size: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set. + /// If unset, there is no minimum percentage, just the `max_options_selected` limit. + pub min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, + /// True if the gauge is stopped + pub is_stopped: bool, + /// UNIX time (seconds) when next epoch may be executed. May be future or past + pub next_epoch: u64, + /// Set this in migration if the gauge should be periodically reset + pub reset: Option, +} + +/// Information about one gauge +#[cw_serde] +pub struct ListGaugesResponse { + pub gauges: Vec, +} + +/// Information about a vote that was cast. +#[cw_serde] +pub struct VoteInfo { + /// The address that voted. + pub voter: String, + /// List of all votes with power + pub votes: Vec, + /// Timestamp when vote was cast. + /// Allow `None` for 0-cost migration from current data + pub cast: Option, +} + +/// Information about a vote. +#[cw_serde] +pub struct VoteResponse { + /// None if no such vote, Some otherwise. + pub vote: Option, +} + +/// Information about all votes on the gauge +#[cw_serde] +pub struct ListVotesResponse { + pub votes: Vec, +} + +/// List all available options ordered by the option string. +/// Also returns the current voting power assigned to that option. +/// You will need to paginate to collect them all. +#[cw_serde] +pub struct ListOptionsResponse { + pub options: Vec<(String, Uint128)>, +} + +/// List the options that were selected in the last executed set. +#[cw_serde] +pub struct LastExecutedSetResponse { + /// `None` if no vote has been executed yet + pub votes: Option>, +} + +/// List the top options by power that would make it into the selected set. +/// Ordered from highest votes to lowest +#[cw_serde] +pub struct SelectedSetResponse { + pub votes: Vec<(String, Uint128)>, +} + +/// Queries the gauge requires from the adapter contract in order to function +#[cw_serde] +#[derive(QueryResponses)] +pub enum AdapterQueryMsg { + #[returns(AllOptionsResponse)] + AllOptions {}, + #[returns(CheckOptionResponse)] + CheckOption { option: String }, + #[returns(SampleGaugeMsgsResponse)] + SampleGaugeMsgs { + /// option along with weight + /// sum of all weights should be 1.0 (within rounding error) + selected: Vec<(String, Decimal)>, + }, +} + +#[cw_serde] +pub struct AllOptionsResponse { + pub options: Vec, +} + +#[cw_serde] +pub struct CheckOptionResponse { + pub valid: bool, +} + +#[cw_serde] +pub struct SampleGaugeMsgsResponse { + // NOTE: I think we will never need CustomMsg here, any reason we should include?? + pub execute: Vec, +} + +#[cw_serde] +pub struct MigrateMsg { + pub gauge_config: Option>, +} + +#[cw_serde] +#[derive(Default)] +pub struct GaugeMigrationConfig { + /// When the next epoch should be executed + pub next_epoch: Option, + /// If set, the gauge will be reset periodically + pub reset: Option, +} + +#[cw_serde] +pub struct ResetMigrationConfig { + /// How often to reset the gauge (in seconds) + pub reset_epoch: u64, + /// When to start the first reset + pub next_reset: u64, +} diff --git a/contracts/gauges/gauge/src/multitest/adapter.rs b/contracts/gauges/gauge/src/multitest/adapter.rs new file mode 100644 index 000000000..aa4af1b8e --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/adapter.rs @@ -0,0 +1,100 @@ +//! Gauge adapter contract to mock in tests. +//! I wrote it so that InstantiateMsg contains list of initially +//! available options. Query for CheckOption checks if option is already added, +//! otherwise returns true - option is valid. + +use cosmwasm_std::{ + coin, to_json_binary, Binary, Coin, CosmosMsg, Decimal, Deps, DepsMut, Empty, Env, MessageInfo, + Order, Response, StdError, StdResult, +}; +use cw_multi_test::{Contract, ContractWrapper}; +use cw_storage_plus::{Item, Map}; +use serde::{Deserialize, Serialize}; + +use crate::msg::{ + AdapterQueryMsg, AllOptionsResponse, CheckOptionResponse, SampleGaugeMsgsResponse, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstantiateMsg { + pub options: Vec, + pub to_distribute: Coin, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ExecuteMsg { + InvalidateOption { option: String }, + AddValidOption { option: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EmptyMsg {} + +const OPTIONS: Map = Map::new("options"); +const TO_DISTRIBUTE: Item = Item::new("to_spend"); + +fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + msg.options + .into_iter() + .try_for_each(|option| OPTIONS.save(deps.storage, option, &true))?; + TO_DISTRIBUTE.save(deps.storage, &msg.to_distribute)?; + Ok(Response::default()) +} + +fn execute( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::InvalidateOption { option } => { + OPTIONS.remove(deps.storage, option); + } + ExecuteMsg::AddValidOption { option } => { + OPTIONS.save(deps.storage, option, &true)?; + } + } + Ok(Response::new()) +} + +fn query(deps: Deps, _env: Env, msg: AdapterQueryMsg) -> Result { + match msg { + AdapterQueryMsg::AllOptions {} => to_json_binary(&AllOptionsResponse { + options: OPTIONS + .keys(deps.storage, None, None, Order::Ascending) + .collect::>>()?, + }), + AdapterQueryMsg::CheckOption { option } => to_json_binary(&CheckOptionResponse { + valid: OPTIONS.has(deps.storage, option), + }), + AdapterQueryMsg::SampleGaugeMsgs { selected } => { + let to_distribute = TO_DISTRIBUTE.load(deps.storage)?; + let mut weights_sum = Decimal::zero(); + let execute = selected + .into_iter() + .map(|(option, weight)| { + weights_sum += weight; + CosmosMsg::Bank(cosmwasm_std::BankMsg::Send { + to_address: option, + amount: vec![coin( + (to_distribute.amount * weight).u128(), + to_distribute.denom.clone(), + )], + }) + }) + .collect::>(); + to_json_binary(&SampleGaugeMsgsResponse { execute }) + } + } +} + +pub fn contract() -> Box> { + let contract = ContractWrapper::new_with_empty(execute, instantiate, query); + Box::new(contract) +} diff --git a/contracts/gauges/gauge/src/multitest/gauge.rs b/contracts/gauges/gauge/src/multitest/gauge.rs new file mode 100644 index 000000000..1b9543f4f --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/gauge.rs @@ -0,0 +1,792 @@ +use cosmwasm_std::{Addr, Decimal, Uint128}; +use dao_voting::voting::Vote; + +use super::suite::{Suite, SuiteBuilder}; + +use crate::error::ContractError; +use crate::msg::{GaugeMigrationConfig, GaugeResponse}; + +const EPOCH: u64 = 7 * 86_400; + +#[test] +fn create_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let response = suite.query_gauge(gauge_contract, 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); +} + +#[test] +fn gauge_can_upgrade_from_self() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + // now let's migrate the gauge and make sure nothing breaks + suite.auto_migrate_gauge(&gauge_contract, None).unwrap(); + + let response = suite.query_gauge(gauge_contract, 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); +} + +#[test] +fn gauge_migrate_with_next_epochs() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + // previous settings + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); + + // now let's migrate the gauge and make sure nothing breaks + let gauge_id = 0; + // change next epoch from 7 to 14 days + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: Some(suite.current_time() + 14 * 86400), + reset: None, + }, + )], + ) + .unwrap(); + + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 14 * 86400, + reset: None, + } + ); + + // try to migrate updating next epoch on nonexisting gauge_id + // actually generic error makes it more difficult to debug in presentable form, I think this is + // enough + let _err = suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + 420, + GaugeMigrationConfig { + next_epoch: Some(suite.current_time() + 14 * 86400), + reset: None, + }, + )], + ) + .unwrap_err(); +} + +/// attach adaptor in instantiate +#[test] +fn execute_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 1000u128 + ); +} + +/// Small helper method to setup the gauge contract. +/// Make sure that `voter` has voting power. +fn init_gauge(suite: &mut Suite, voters: &[&str]) -> Addr { + suite.next_block(); + suite + .propose_update_proposal_module(voters[0], None) + .unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + for voter in voters { + suite + .place_vote_single(*voter, proposal, Vote::Yes) + .unwrap(); + } + suite.next_block(); + suite.execute_single_proposal(voters[0], proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + // Second proposal module is cw proposal single, first one is newly added gauge + assert_eq!(proposal_modules.len(), 2); + proposal_modules[1].clone() +} + +#[test] +fn query_last_execution() { + let voter1 = "voter1"; + let voter2 = "voter2"; + + let reward_to_distribute = (2000, "ujuno"); + + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2, gauge_contract.as_str()], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + let gauge_id = 0; + + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + None, + "not executed yet" + ); + + // vote + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter1.to_owned())) + .unwrap(); + suite + .place_votes( + &gauge_contract, + voter2, + gauge_id, + vec![ + (gauge_contract.to_string(), Decimal::percent(40)), + (voter2.to_owned(), Decimal::percent(60)), + ], + ) + .unwrap(); + // wait until epoch passes + suite.advance_time(EPOCH); + // execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // should return the executed set now + let expected_votes = Some(vec![ + (voter1.to_owned(), 100u128.into()), + (voter2.to_string(), 60u128.into()), + (gauge_contract.to_string(), 40u128.into()), + ]); + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + expected_votes + ); + + // change votes + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter2.to_owned())) + .unwrap(); + suite + .place_vote(&gauge_contract, voter2, gauge_id, None) + .unwrap(); + + // wait until epoch passes + suite.advance_time(EPOCH); + + // should not change last execution yet + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + expected_votes + ); + + // execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // now it should be changed + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + Some(vec![(voter2.to_owned(), 100u128.into())]) + ); +} + +#[test] +fn execute_gauge_twice_same_epoch() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], (1000, "ujuno"), None, None) // reward per + // epoch + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 1000u128 + ); + + // execution twice same time won't work + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + let next_epoch = suite.current_time() + EPOCH; + assert_eq!( + ContractError::EpochNotReached { + gauge_id, + current_epoch: suite.current_time(), + next_epoch + }, + err.downcast().unwrap() + ); + + // just before next epoch fails as well + suite.advance_time(EPOCH - 1); + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!( + ContractError::EpochNotReached { + gauge_id, + current_epoch: suite.current_time(), + next_epoch + }, + err.downcast().unwrap() + ); + + // another epoch is fine + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 2000u128 + ); +} + +#[test] +fn execute_stopped_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // stop the gauge by not-owner + let err = suite + .stop_gauge(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + // stop the gauge by owner + suite + .stop_gauge(&gauge_contract, suite.owner.clone(), gauge_id) + .unwrap(); + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + // Despite gauge being stopped, user + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!( + ContractError::GaugeStopped(gauge_id), + err.downcast().unwrap() + ); +} + +#[test] +fn update_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let second_gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "uusdc"), + None, + None, + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ] + ); + + // update parameters on the first gauge + let owner = suite.owner.clone(); + let new_epoch = EPOCH * 2; + let new_min_percent = Some(Decimal::percent(10)); + let new_max_options = 15; + let new_max_available_percentage = Some(Decimal::percent(5)); + suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + new_max_options, + new_max_available_percentage, + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: new_epoch, + min_percent_selected: new_min_percent, + max_options_selected: new_max_options, + max_available_percentage: new_max_available_percentage, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ] + ); + + // clean setting of min_percent_selected on second gauge + suite + .update_gauge( + &owner, + gauge_contract.clone(), + 1, + None, + Some(Decimal::zero()), + None, + None, + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: new_epoch, + min_percent_selected: new_min_percent, + max_options_selected: new_max_options, + max_available_percentage: new_max_available_percentage, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: None, + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ] + ); + + // Not owner cannot update gauges + let err = suite + .update_gauge( + "notowner", + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + new_max_options, + None, + ) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + let err = suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + 50, + new_min_percent, + new_max_options, + None, + ) + .unwrap_err(); + assert_eq!(ContractError::EpochSizeTooShort {}, err.downcast().unwrap()); + + let err = suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + Some(Decimal::one()), + new_max_options, + None, + ) + .unwrap_err(); + assert_eq!( + ContractError::MinPercentSelectedTooBig {}, + err.downcast().unwrap() + ); + + let err = suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + 0, + None, + ) + .unwrap_err(); + assert_eq!( + ContractError::MaxOptionsSelectedTooSmall {}, + err.downcast().unwrap() + ); + + let err = suite + .update_gauge( + &owner, + gauge_contract, + 1, + None, + Some(Decimal::zero()), + None, + Some(Decimal::percent(101)), + ) + .unwrap_err(); + assert_eq!( + ContractError::MaxAvailablePercentTooBig {}, + err.downcast().unwrap() + ); +} diff --git a/contracts/gauges/gauge/src/multitest/mod.rs b/contracts/gauges/gauge/src/multitest/mod.rs new file mode 100644 index 000000000..7ecb09742 --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/mod.rs @@ -0,0 +1,6 @@ +mod adapter; +mod gauge; +mod reset; +mod suite; +mod tally; +mod voting; diff --git a/contracts/gauges/gauge/src/multitest/reset.rs b/contracts/gauges/gauge/src/multitest/reset.rs new file mode 100644 index 000000000..ffe572bd4 --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/reset.rs @@ -0,0 +1,417 @@ +use cosmwasm_std::{Decimal, StdError, Uint128}; +use dao_voting::voting::Vote; + +use crate::{ + msg::{GaugeMigrationConfig, GaugeResponse, ResetMigrationConfig}, + multitest::suite::SuiteBuilder, + ContractError, +}; + +const EPOCH: u64 = 7 * 86_400; +const RESET_EPOCH: u64 = 30 * 86_400; + +#[test] +fn basic_gauge_reset() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config( + &[voter1, voter2], + reward_to_distribute, + None, + RESET_EPOCH, + ) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // cannot reset before epoch has passed + assert_eq!( + ContractError::ResetEpochNotPassed {}, + suite + .reset_gauge("anyone", &gauge_contract, gauge_id, 10) + .unwrap_err() + .downcast() + .unwrap() + ); + + // reset + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 100) // 100 is way more than needed + .unwrap(); + + // check that gauge was reset + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![]); + let votes = suite.query_list_votes(&gauge_contract, gauge_id).unwrap(); + assert_eq!(votes, vec![]); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + None, + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + None, + ); + // options should still be there + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // actually execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + assert_eq!( + suite + .query_balance(suite.core.as_str(), reward_to_distribute.1) + .unwrap(), + reward_to_distribute.0, + "nothing should be distributed yet, since all votes were reset" + ); + + // vote again + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter2.to_owned()), + ) + .unwrap(); + + // check that vote counts + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![("voter2".to_owned(), Uint128::new(100))]); + + // another epoch is fine + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter2, reward_to_distribute.1).unwrap(), + 2000u128 + ); +} + +#[test] +fn gauge_migrate_with_reset() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + // setup gauge + suite.next_block(); + suite.propose_update_proposal_module(voter1, None).unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite.next_block(); + suite.execute_single_proposal(voter1, proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + assert_eq!(proposal_modules.len(), 2); + let gauge_contract = proposal_modules[1].clone(); + + // create adapter + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + // previous settings + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); + + // now let's migrate the gauge and make sure nothing breaks + let gauge_id = 0; + // try to migrate to past reset should fail + assert_eq!( + ContractError::from(StdError::generic_err( + "Next reset value cannot be earlier then current epoch!" + )), + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() - 1, + }), + }, + )], + ) + .unwrap_err() + .downcast() + .unwrap() + ); + + // migrate to reset epoch + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() + 100, + }), + }, + )], + ) + .unwrap(); + + // check that gauge was migrated + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: Some(crate::state::Reset { + last: None, + reset_each: RESET_EPOCH, + next: suite.current_time() + 100, + }), + } + ); +} + +#[test] +fn gauge_migrate_keeps_last_reset() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + // setup gauge + suite.next_block(); + suite.propose_update_proposal_module(voter1, None).unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite.next_block(); + suite.execute_single_proposal(voter1, proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + assert_eq!(proposal_modules.len(), 2); + let gauge_contract = proposal_modules[1].clone(); + + // create adapter + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + Some(RESET_EPOCH), + ) + .unwrap(); + let gauge_id = 0; + + // reset gauge once before migration + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); + let gauge = suite.query_gauge(gauge_contract.clone(), gauge_id).unwrap(); + assert_eq!(gauge.reset.unwrap().last, Some(suite.current_time())); + + // now let's migrate the gauge and make sure nothing breaks + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() + 100, + }), + }, + )], + ) + .unwrap(); + + // check that last reset is still the same + let gauge = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!(gauge.reset.unwrap().last, Some(suite.current_time())); +} + +#[test] +fn partial_reset() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config( + &[voter1, voter2], + reward_to_distribute, + None, + RESET_EPOCH, + ) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for the gauge options + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter2.to_owned()), + ) + .unwrap(); + + // start resetting + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); + + // try to vote during reset + assert_eq!( + ContractError::GaugeResetting(gauge_id), + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter2.to_owned())) + .unwrap_err() + .downcast() + .unwrap() + ); + // check selected set query + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![]); + // check votes list + let votes = suite.query_list_votes(&gauge_contract, gauge_id).unwrap(); + assert_eq!(votes, vec![]); + + // finish resetting + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); +} diff --git a/contracts/gauges/gauge/src/multitest/suite.rs b/contracts/gauges/gauge/src/multitest/suite.rs new file mode 100644 index 000000000..de16b1d32 --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/suite.rs @@ -0,0 +1,800 @@ +use anyhow::Result as AnyResult; +use cosmwasm_std::{ + coin, to_json_binary, Addr, Coin, CosmosMsg, Decimal, StdResult, Uint128, WasmMsg, +}; +use cw4::Member; +use cw4_group::msg::ExecuteMsg as Cw4ExecuteMsg; +use cw_multi_test::{App, AppResponse, ContractWrapper, Executor}; +use cw_utils::Duration; +use dao_interface::{ + msg::{ + ExecuteMsg as CoreExecuteMsg, InstantiateMsg as CoreInstantiateMsg, + QueryMsg as CoreQueryMsg, + }, + state::{Admin, ModuleInstantiateInfo, ProposalModule}, +}; +use dao_proposal_single::{ + msg::ExecuteMsg as ProposalSingleExecuteMsg, + msg::InstantiateMsg as ProposalSingleInstantiateMsg, msg::QueryMsg as ProposalSingleQueryMsg, + query::ProposalListResponse, +}; +use dao_voting::{ + pre_propose::PreProposeInfo, + proposal::SingleChoiceProposeMsg, + threshold::{PercentageThreshold, Threshold}, + voting::Vote, +}; +use dao_voting_cw4::msg::{InstantiateMsg as VotingInstantiateMsg, QueryMsg as VotingQueryMsg}; + +use super::adapter::{ + contract as adapter_contract, ExecuteMsg as AdapterExecuteMsg, + InstantiateMsg as AdapterInstantiateMsg, +}; +use crate::msg::{ + ExecuteMsg, GaugeConfig, GaugeMigrationConfig, GaugeResponse, InstantiateMsg, + LastExecutedSetResponse, ListGaugesResponse, ListOptionsResponse, ListVotesResponse, + MigrateMsg, QueryMsg, SelectedSetResponse, VoteInfo, VoteResponse, +}; + +type GaugeId = u64; + +pub const BLOCK_TIME: u64 = 5; + +fn store_gauge(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate), + ); + + app.store_code(contract) +} + +fn store_group(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + cw4_group::contract::execute, + cw4_group::contract::instantiate, + cw4_group::contract::query, + )); + + app.store_code(contract) +} + +fn store_voting(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + dao_voting_cw4::contract::execute, + dao_voting_cw4::contract::instantiate, + dao_voting_cw4::contract::query, + ) + .with_reply_empty(dao_voting_cw4::contract::reply), + ); + + app.store_code(contract) +} + +fn store_proposal_single(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + dao_proposal_single::contract::execute, + dao_proposal_single::contract::instantiate, + dao_proposal_single::contract::query, + )); + + app.store_code(contract) +} + +fn store_core(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + dao_dao_core::contract::execute, + dao_dao_core::contract::instantiate, + dao_dao_core::contract::query, + ) + .with_reply_empty(dao_dao_core::contract::reply), + ); + + app.store_code(contract) +} + +#[derive(Debug)] +pub struct SuiteBuilder { + voting_members: Vec, + initial_core_balance: Option, +} + +impl SuiteBuilder { + pub fn new() -> Self { + Self { + voting_members: vec![], + initial_core_balance: None, + } + } + + pub fn with_core_balance(mut self, balance: (u128, &str)) -> Self { + self.initial_core_balance = Some(coin(balance.0, balance.1)); + self + } + + pub fn with_voting_members(mut self, members: &[(&str, u64)]) -> Self { + self.voting_members = members + .iter() + .map(|(addr, weight)| Member { + addr: addr.to_string(), + weight: *weight, + }) + .collect::>(); + self + } + + #[track_caller] + pub fn build(self) -> Suite { + let mut app = App::default(); + let owner = Addr::unchecked("owner"); + + // instantiate cw4-voting as voting contract + let group_code_id = store_group(&mut app); + let voting_code_id = store_voting(&mut app); + let voting_module = ModuleInstantiateInfo { + code_id: voting_code_id, + msg: to_json_binary(&VotingInstantiateMsg { + group_contract: dao_voting_cw4::msg::GroupContract::New { + cw4_group_code_id: group_code_id, + initial_members: self.voting_members, + }, + }) + .unwrap(), + admin: Some(Admin::Address { + addr: owner.to_string(), + }), + label: "CW4 Voting Contract".to_owned(), + funds: vec![], + }; + + // instantiate proposal_single module + let proposal_single_code_id = store_proposal_single(&mut app); + let proposal_module = ModuleInstantiateInfo { + code_id: proposal_single_code_id, + msg: to_json_binary(&ProposalSingleInstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(66666666), + min_voting_period: None, + allow_revoting: false, + only_members_execute: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + close_proposal_on_execution_failure: false, + veto: None, + }) + .unwrap(), + admin: Some(Admin::Address { + addr: owner.to_string(), + }), + label: "Proposal Single Contract".to_owned(), + funds: vec![], + }; + + // intantiate core contract, + let core_code_id = store_core(&mut app); + let core = app + .instantiate_contract( + core_code_id, + owner.clone(), + &CoreInstantiateMsg { + admin: Some(owner.to_string()), + name: "CW Core contract".to_owned(), + description: "Hub between voting end executing".to_owned(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: voting_module, + proposal_modules_instantiate_info: vec![proposal_module], + initial_items: None, + dao_uri: None, + }, + &[], + "CW CORE", + None, + ) + .unwrap(); + + if let Some(core_balance) = self.initial_core_balance { + app.init_modules(|router, _, storage| -> AnyResult<()> { + router.bank.init_balance(storage, &core, vec![core_balance]) + }) + .unwrap(); + } + + let voting_contract: Addr = app + .wrap() + .query_wasm_smart(&core, &CoreQueryMsg::VotingModule {}) + .unwrap(); + let group_contract: Addr = app + .wrap() + .query_wasm_smart(voting_contract.clone(), &VotingQueryMsg::GroupContract {}) + .unwrap(); + let proposal_single_contract: Vec = app + .wrap() + .query_wasm_smart( + &core, + &CoreQueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + let gauge_code_id = store_gauge(&mut app); + let gauge_adapter_code_id = app.store_code(adapter_contract()); + + Suite { + owner: owner.to_string(), + app, + core, + group_contract, + voting: voting_contract, + proposal_single: proposal_single_contract[0].address.clone(), + gauge_code_id, + gauge_adapter_code_id, + } + } +} + +pub struct Suite { + pub owner: String, + pub app: App, + pub core: Addr, + pub group_contract: Addr, + pub voting: Addr, + pub proposal_single: Addr, + pub gauge_code_id: u64, + pub gauge_adapter_code_id: u64, +} + +impl Suite { + pub fn advance_blocks(&mut self, blocks: u64) { + self.app.update_block(|block| { + block.time = block.time.plus_seconds(BLOCK_TIME * blocks); + block.height += blocks; + }); + } + + pub fn advance_time(&mut self, seconds: u64) { + self.app.update_block(|block| { + block.time = block.time.plus_seconds(seconds); + block.height += seconds / BLOCK_TIME; + }); + } + + pub fn next_block(&mut self) { + self.advance_blocks(1) + } + + pub fn current_time(&self) -> u64 { + self.app.block_info().time.seconds() + } + + pub fn stop_gauge( + &mut self, + gauge: &Addr, + sender: impl Into, + gauge_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge.clone(), + &ExecuteMsg::StopGauge { gauge: gauge_id }, + &[], + ) + } + + pub fn add_option( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::AddOption { + gauge: gauge_id, + option: option.into(), + }, + &[], + ) + } + + pub fn remove_option( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::RemoveOption { + gauge: gauge_id, + option: option.into(), + }, + &[], + ) + } + + /// Helper to remove an option from the test gauge adapter + pub fn invalidate_option( + &mut self, + gauge_adapter: &Addr, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(self.owner.clone()), + gauge_adapter.clone(), + &AdapterExecuteMsg::InvalidateOption { + option: option.into(), + }, + &[], + ) + } + + /// Helper to add an option to the test gauge adapter + pub fn add_valid_option( + &mut self, + gauge_adapter: &Addr, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(self.owner.clone()), + gauge_adapter.clone(), + &AdapterExecuteMsg::AddValidOption { + option: option.into(), + }, + &[], + ) + } + + /// Helper to vote for a single option + pub fn place_vote( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into>, + ) -> AnyResult { + self.place_votes( + gauge, + voter, + gauge_id, + option.into().map(|o| vec![(o, Decimal::one())]), + ) + } + + pub fn place_votes( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + votes: impl Into>>, + ) -> AnyResult { + let votes = votes.into().map(|v| { + v.into_iter() + .map(|(option, weight)| crate::state::Vote { option, weight }) + .collect::>() + }); + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::PlaceVotes { + gauge: gauge_id, + votes, + }, + &[], + ) + } + + pub fn execute_options( + &mut self, + gauge: &Addr, + sender: impl Into, + gauge_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge.clone(), + &ExecuteMsg::Execute { gauge: gauge_id }, + &[], + ) + } + + pub fn query_gauge(&self, gauge_contract: Addr, id: u64) -> StdResult { + self.app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::Gauge { id }) + } + + pub fn query_gauges(&self, gauge_contract: Addr) -> StdResult> { + Ok(self + .app + .wrap() + .query_wasm_smart::( + gauge_contract, + &QueryMsg::ListGauges { + start_after: None, + limit: None, + }, + )? + .gauges) + } + + pub fn query_selected_set( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult> { + let set: SelectedSetResponse = self + .app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::SelectedSet { gauge: id })?; + Ok(set.votes) + } + + pub fn query_last_executed_set( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult>> { + let set: LastExecutedSetResponse = self + .app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::LastExecutedSet { gauge: id })?; + Ok(set.votes) + } + + pub fn query_list_options( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult> { + let set: ListOptionsResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::ListOptions { + gauge: id, + start_after: None, + limit: None, + }, + )?; + Ok(set.options) + } + + pub fn query_vote( + &self, + gauge_contract: &Addr, + id: u64, + voter: impl Into, + ) -> StdResult> { + let vote: VoteResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::Vote { + gauge: id, + voter: voter.into(), + }, + )?; + Ok(vote.vote) + } + + pub fn query_list_votes(&self, gauge_contract: &Addr, id: u64) -> StdResult> { + let vote: ListVotesResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::ListVotes { + gauge: id, + start_after: None, + limit: None, + }, + )?; + Ok(vote.votes) + } + + // ----------------------------------------------------- + + pub fn propose_update_proposal_module( + &mut self, + proposer: impl Into, + gauge_config: impl Into>>, + ) -> AnyResult { + let propose_msg = ProposalSingleExecuteMsg::Propose(SingleChoiceProposeMsg { + title: "gauge as proposal module".to_owned(), + description: "Propose core to set gauge as proposal module".to_owned(), + msgs: vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: self.core.to_string(), + msg: to_json_binary(&CoreExecuteMsg::UpdateProposalModules { + to_add: vec![ModuleInstantiateInfo { + code_id: self.gauge_code_id, + msg: to_json_binary(&InstantiateMsg { + voting_powers: self.voting.to_string(), + hook_caller: self.group_contract.to_string(), + owner: self.owner.clone(), + gauges: gauge_config.into(), + })?, + admin: Some(Admin::Address { + addr: self.owner.clone(), + }), + label: "CW4 Voting Contract".to_owned(), + funds: vec![], + }], + to_disable: vec![], + })?, + funds: vec![], + })], + proposer: None, + vote: None, + }); + self.app.execute_contract( + Addr::unchecked(proposer), + self.proposal_single.clone(), + &propose_msg, + &[], + ) + } + + pub fn propose_update_proposal_module_custom_hook_caller( + &mut self, + proposer: impl Into, + hook_caller: impl Into, + gauge_config: impl Into>>, + ) -> AnyResult { + let propose_msg = ProposalSingleExecuteMsg::Propose(SingleChoiceProposeMsg { + title: "gauge as proposal module".to_owned(), + description: "Propose core to set gauge as proposal module".to_owned(), + msgs: vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: self.core.to_string(), + msg: to_json_binary(&CoreExecuteMsg::UpdateProposalModules { + to_add: vec![ModuleInstantiateInfo { + code_id: self.gauge_code_id, + msg: to_json_binary(&InstantiateMsg { + voting_powers: self.voting.to_string(), + hook_caller: Addr::unchecked(hook_caller).to_string(), + owner: self.owner.clone(), + gauges: gauge_config.into(), + })?, + admin: Some(Admin::Address { + addr: self.owner.clone(), + }), + label: "CW4 Voting Contract".to_owned(), + funds: vec![], + }], + to_disable: vec![], + })?, + funds: vec![], + })], + proposer: None, + vote: None, + }); + self.app.execute_contract( + Addr::unchecked(proposer), + self.proposal_single.clone(), + &propose_msg, + &[], + ) + } + + pub fn propose_add_membership_change_hook( + &mut self, + proposer: impl Into, + gauge_contract: Addr, + ) -> AnyResult { + let propose_msg = ProposalSingleExecuteMsg::Propose(SingleChoiceProposeMsg { + title: "Add membership change hook".to_owned(), + description: "Propose core to add membership change hook for gauge".to_owned(), + msgs: vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: self.group_contract.to_string(), + msg: to_json_binary(&Cw4ExecuteMsg::AddHook { + addr: gauge_contract.to_string(), + })?, + funds: vec![], + })], + proposer: None, + vote: None, + }); + self.app.execute_contract( + Addr::unchecked(proposer), + self.proposal_single.clone(), + &propose_msg, + &[], + ) + } + + pub fn instantiate_adapter_and_create_gauge( + &mut self, + gauge_contract: Addr, + options: &[&str], + to_distribute: (u128, &str), + max_available_percentage: impl Into>, + reset_epoch: impl Into>, + ) -> AnyResult { + let option = self.instantiate_adapter_and_return_config( + options, + to_distribute, + max_available_percentage, + reset_epoch, + )?; + let gauge_adapter = option.adapter.clone(); + self.app.execute_contract( + Addr::unchecked(&self.owner), + gauge_contract, + &ExecuteMsg::CreateGauge(option), + &[], + )?; + Ok(Addr::unchecked(gauge_adapter)) + } + + pub fn instantiate_adapter_and_return_config( + &mut self, + options: &[&str], + to_distribute: (u128, &str), + max_available_percentage: impl Into>, + reset_epoch: impl Into>, + ) -> AnyResult { + let gauge_adapter = self.app.instantiate_contract( + self.gauge_adapter_code_id, + Addr::unchecked(&self.owner), + &AdapterInstantiateMsg { + options: options.iter().map(|&s| s.into()).collect(), + to_distribute: coin(to_distribute.0, to_distribute.1), + }, + &[], + "gauge adapter", + None, + )?; + + Ok(GaugeConfig { + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: 7 * 86400, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: max_available_percentage.into(), + reset_epoch: reset_epoch.into(), + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn update_gauge( + &mut self, + sender: &str, + gauge_contract: Addr, + gauge_id: u64, + epoch_size: impl Into>, + min_percent_selected: Option, + max_options_selected: impl Into>, + max_available_percentage: impl Into>, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge_contract, + &ExecuteMsg::UpdateGauge { + gauge_id, + epoch_size: epoch_size.into(), + min_percent_selected, + max_options_selected: max_options_selected.into(), + max_available_percentage: max_available_percentage.into(), + }, + &[], + ) + } + + pub fn reset_gauge( + &mut self, + sender: &str, + gauge_contract: &Addr, + gauge: u64, + batch_size: u32, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge_contract.clone(), + &ExecuteMsg::ResetGauge { gauge, batch_size }, + &[], + ) + } + + pub fn place_vote_single( + &mut self, + voter: impl Into, + proposal_id: u64, + vote: Vote, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + self.proposal_single.clone(), + &ProposalSingleExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + } + + pub fn execute_single_proposal( + &mut self, + executor: impl Into, + proposal_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(executor), + self.proposal_single.clone(), + &ProposalSingleExecuteMsg::Execute { proposal_id }, + &[], + ) + } + + pub fn force_update_members( + &mut self, + remove: Vec, + add: Vec, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(self.core.clone()), + self.group_contract.clone(), + &Cw4ExecuteMsg::UpdateMembers { remove, add }, + &[], + ) + } + + pub fn list_proposals(&self) -> StdResult> { + let list: ProposalListResponse = self.app.wrap().query_wasm_smart( + self.proposal_single.clone(), + &ProposalSingleQueryMsg::ListProposals { + start_after: None, + limit: None, + }, + )?; + Ok(list.proposals.into_iter().map(|prop| prop.id).collect()) + } + + pub fn query_proposal_modules(&self) -> StdResult> { + let proposal_module: Vec = self + .app + .wrap() + .query_wasm_smart( + self.core.clone(), + &CoreQueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + proposal_module + .into_iter() + .map(|pm| Ok(pm.address)) + .collect() + } + + pub fn query_balance(&self, account: &str, denom: &str) -> StdResult { + let balance = self.app.wrap().query_balance(account, denom)?; + Ok(balance.amount.u128()) + } + + pub fn auto_migrate_gauge( + &mut self, + gauge: &Addr, + gauge_config: impl Into>>, + ) -> AnyResult { + let sender = Addr::unchecked(&self.owner); + + self.app.migrate_contract( + sender, + gauge.clone(), + &MigrateMsg { + gauge_config: gauge_config.into(), + }, + self.gauge_code_id, + ) + } +} diff --git a/contracts/gauges/gauge/src/multitest/tally.rs b/contracts/gauges/gauge/src/multitest/tally.rs new file mode 100644 index 000000000..9de07ecb4 --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/tally.rs @@ -0,0 +1,333 @@ +use cosmwasm_std::Uint128; +use dao_voting::voting::Vote; + +use super::suite::SuiteBuilder; + +#[test] +fn multiple_options_one_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let voter3 = "voter3"; + let voter4 = "voter4"; + let voter5 = "voter5"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority... + (voter2, 120), + (voter3, 130), + (voter4, 140), + (voter5, 150), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2", "option3", "option4", "option5"], + reward_to_distribute, + None, + None, + ) + .unwrap(); + let gauge_id = 0; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("option1".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter3.to_owned(), + gauge_id, + Some("option3".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter4.to_owned(), + gauge_id, + Some("option4".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter5.to_owned(), + gauge_id, + Some("option5".into()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(600)), + ("option5".to_owned(), Uint128::new(150)), + ("option4".to_owned(), Uint128::new(140)), + ("option3".to_owned(), Uint128::new(130)), + ("option2".to_owned(), Uint128::new(120)) + ] + ); + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("option2".into()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + selected_set, + vec![ + ("option2".to_owned(), Uint128::new(720)), + ("option5".to_owned(), Uint128::new(150)), + ("option4".to_owned(), Uint128::new(140)), + ("option3".to_owned(), Uint128::new(130)), + ] + ); +} + +/// create one in instantiate, other later via create +#[test] +fn multiple_options_two_gauges() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let voter3 = "voter3"; + let voter4 = "voter4"; + let voter5 = "voter5"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority + (voter2, 120), + (voter3, 130), + (voter4, 140), + (voter5, 150), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config( + &["option1", "option2"], + reward_to_distribute, + None, + None, + ) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let first_gauge_id = 0; + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option3", "option4", "option5"], + reward_to_distribute, + None, + None, + ) + .unwrap(); + let second_gauge_id = 1; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter3.to_owned(), + second_gauge_id, + Some("option3".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter4.to_owned(), + second_gauge_id, + Some("option5".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter5.to_owned(), + second_gauge_id, + Some("option5".into()), + ) + .unwrap(); + + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![("option2".to_owned(), Uint128::new(720))] + ); + + let selected_set = suite + .query_selected_set(&gauge_contract, second_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![ + ("option5".to_owned(), Uint128::new(290)), + ("option3".to_owned(), Uint128::new(130)), + ] + ); +} + +#[test] +fn not_voted_options_are_not_selected() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority + (voter2, 120), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2", "option3", "option4"], + reward_to_distribute, + None, + None, + ) + .unwrap(); + let first_gauge_id = 0; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option1".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(600)), + ("option2".to_owned(), Uint128::new(120)), + ] + ); + + // first voter changes vote to option2 + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![("option2".to_owned(), Uint128::new(720)),] + ); +} diff --git a/contracts/gauges/gauge/src/multitest/voting.rs b/contracts/gauges/gauge/src/multitest/voting.rs new file mode 100644 index 000000000..962daa1ac --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/voting.rs @@ -0,0 +1,1263 @@ +use cosmwasm_std::{Addr, Decimal, Uint128}; +use cw4::Member; +use cw_multi_test::Executor; +use dao_hooks::nft_stake::{NftStakeChangedExecuteMsg, NftStakeChangedHookMsg}; +use dao_hooks::stake::StakeChangedExecuteMsg; +use dao_voting::voting::Vote; + +use super::suite::SuiteBuilder; +use crate::error::ContractError; +use crate::msg::VoteInfo; + +const EPOCH: u64 = 7 * 86_400; + +#[test] +fn add_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // gauge returns list all options; it does query adapter at initialization + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!(options.len(), 2); + + // add more valid options to gauge adapter + suite + .add_valid_option(&gauge_adapter, "addedoption1") + .unwrap(); + suite + .add_valid_option(&gauge_adapter, "addedoption2") + .unwrap(); + + // Voting members can add options + suite + .add_option(&gauge_contract, voter1, gauge_id, "addedoption1") + .unwrap(); + suite + .add_option(&gauge_contract, voter2, gauge_id, "addedoption2") + .unwrap(); + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // added options are automatically voted for by creators + assert_eq!( + options, + vec![ + ("addedoption1".to_owned(), Uint128::zero()), + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // add another valid option to gauge adapter + suite + .add_valid_option(&gauge_adapter, "addedoption3") + .unwrap(); + // Non-voting members cannot add options + let err = suite + .add_option(&gauge_contract, "random_voter", gauge_id, "addedoption3") + .unwrap_err(); + assert_eq!( + ContractError::NoVotingPower("random_voter".to_owned()), + err.downcast().unwrap() + ); +} + +#[test] +fn remove_option() { + let owner = "owner"; + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // gauge returns list all options; it does query adapter at initialization + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // add new valid options to the gauge adapter + suite.add_valid_option(&adapter, "addedoption1").unwrap(); + suite.add_valid_option(&adapter, "addedoption2").unwrap(); + + // Voting members can add options + suite + .add_option(&gauge_contract, voter1, gauge_id, "addedoption1") + .unwrap(); + suite + .add_option(&gauge_contract, voter2, gauge_id, "addedoption2") + .unwrap(); + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // added options are automatically voted for by creators + assert_eq!( + options, + vec![ + ("addedoption1".to_owned(), Uint128::zero()), + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // owner can remove an option that has been added already + suite + .remove_option(&gauge_contract, owner, gauge_id, "addedoption1") + .unwrap(); + + // Anyone else cannot remove options + let err = suite + .remove_option(&gauge_contract, voter1, gauge_id, "addedoption2") + .unwrap_err(); + + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // one has been removed + assert_eq!( + options, + vec![ + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + suite.invalidate_option(&adapter, "addedoption2").unwrap(); + + // owner can remove an option that is no longer valid + suite + .remove_option(&gauge_contract, owner, gauge_id, "addedoption2") + .unwrap(); + + // Both options are now removed + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); +} + +fn simple_vote( + voter: &str, + option: &str, + percentage: u64, + cast: impl Into>, +) -> VoteInfo { + VoteInfo { + voter: voter.to_string(), + votes: vec![crate::state::Vote { + option: option.to_string(), + weight: Decimal::percent(percentage), + }], + cast: cast.into(), + } +} + +fn multi_vote(voter: &str, votes: &[(&str, u64)], cast: impl Into>) -> VoteInfo { + let votes = votes + .iter() + .map(|(opt, percentage)| crate::state::Vote { + option: opt.to_string(), + weight: Decimal::percent(*percentage), + }) + .collect(); + VoteInfo { + voter: voter.to_string(), + votes, + cast: cast.into(), + } +} + +#[test] +fn vote_for_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 90, suite.current_time()), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::new(90))]); + + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 90, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // placing vote again overwrites previous ones + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![("option1".to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![("option1".to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, "option1", 90, suite.current_time()), + simple_vote(voter2, "option1", 90, suite.current_time()), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // vote for non-existing option + let err = suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("random option".to_owned()), + ) + .unwrap_err(); + assert_eq!( + ContractError::OptionDoesNotExists { + option: "random option".to_owned(), + gauge_id + }, + err.downcast().unwrap() + ); +} + +#[test] +fn remove_vote() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100, suite.current_time()), + simple_vote(voter2, voter1, 100, suite.current_time()), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // remove vote + suite + .place_vote(&gauge_contract, voter1.to_owned(), gauge_id, None) + .unwrap(); + assert_eq!( + vec![simple_vote(voter2, voter1, 100, suite.current_time())], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + None + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + Some(simple_vote(voter2, voter1, 100, suite.current_time())), + ); + + // remove nonexisting vote + let err = suite + .place_vote(&gauge_contract, voter1.to_owned(), gauge_id, None) + .unwrap_err(); + assert_eq!( + ContractError::CannotRemoveNonexistingVote {}, + err.downcast().unwrap() + ); +} + +#[test] +fn votes_stays_the_same_after_execution() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100, suite.current_time() - EPOCH), + simple_vote(voter2, voter1, 100, suite.current_time() - EPOCH) + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100, suite.current_time() - EPOCH), + simple_vote(voter2, voter1, 100, suite.current_time() - EPOCH) + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + Some(simple_vote( + voter1, + voter1, + 100, + suite.current_time() - EPOCH + )), + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + Some(simple_vote( + voter2, + voter1, + 100, + suite.current_time() - EPOCH + )), + ); +} + +#[test] +fn vote_for_max_capped_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + Some(Decimal::percent(10)), + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // wait until epoch passes + suite.advance_time(EPOCH); + + // add more valid options to gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + + // vote 100% voting power on 'voter1' option (100 weight) + suite + .place_vote( + &gauge_contract, + voter1, + gauge_id, + Some("option1".to_owned()), + ) + .unwrap(); + // vote 10% voting power on 'voter2' option (10 weight) + suite + .place_votes( + &gauge_contract, + voter2, + gauge_id, + vec![("option2".to_owned(), Decimal::percent(10))], + ) + .unwrap(); + + assert_eq!( + vec![ + multi_vote(voter1, &[("option1", 100)], suite.current_time()), + multi_vote(voter2, &[("option2", 10)], suite.current_time()), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // Despite 'option1' having 100 voting power and option2 having 10 voting power, + // because of max vote cap set to 10% now 'option1' will have its power decreased to 10% * 110 + // 'option2' stays at 10 voting power as it was below 10% of total votes + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(11)), + ("option2".to_owned(), Uint128::new(10)) + ] + ); +} + +#[test] +fn membership_voting_power_change() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .with_core_balance((10000, "ujuno")) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + // Setup membership change hooks + suite + .propose_add_membership_change_hook(voter1.to_string(), gauge_contract.clone()) + .unwrap(); + let proposal = suite.list_proposals().unwrap()[1]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 90, suite.current_time()), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::new(90))]); + + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 90, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let pre_voter1_takeover_gauge_set = + suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Voter one's option is least popular + assert_eq!( + pre_voter1_takeover_gauge_set, + vec![ + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)), + ("voter1".to_string(), Uint128::new(90)) + ] + ); + + // Force update members, giving voter 1 more power + suite + .force_update_members( + vec![], + vec![Member { + addr: voter1.to_string(), + weight: 1000, + }], + ) + .unwrap(); + suite.next_block(); + + let current_gauge_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Currect selected set should be different than before voter1 got power + assert_ne!(pre_voter1_takeover_gauge_set, current_gauge_set); + + // Voter1 option is now most popular + assert_eq!( + current_gauge_set, + vec![ + ("voter1".to_string(), Uint128::new(900)), + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)) + ] + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // Force update members, kick out voter 1 + suite + .force_update_members(vec![voter1.to_string()], vec![]) + .unwrap(); + suite.next_block(); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let current_gauge_set = suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(); + + // Voter1 removed and so is the one thing they voted for + assert_eq!( + current_gauge_set, + Some(vec![ + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)) + ]) + ); +} + +#[test] +fn token_staking_voting_power_change() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let hook_caller = "token-staking-contract"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .with_core_balance((10000, "ujuno")) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module_custom_hook_caller( + voter1.to_string(), + hook_caller.to_string(), + None, + ) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 90, suite.current_time()), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::new(90))]); + + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 90, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let pre_voter1_takeover_gauge_set = + suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Voter one's option is least popular + assert_eq!( + pre_voter1_takeover_gauge_set, + vec![ + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)), + ("voter1".to_string(), Uint128::new(90)) + ] + ); + + // Use hook caller to mock voter1 staking + suite + .app + .execute_contract( + Addr::unchecked(hook_caller), + gauge_contract.clone(), + &StakeChangedExecuteMsg::StakeChangeHook( + dao_hooks::stake::StakeChangedHookMsg::Stake { + addr: Addr::unchecked(voter1), + amount: Uint128::new(900), + }, + ), + &[], + ) + .unwrap(); + + suite.next_block(); + + let current_gauge_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Currect selected set should be different than before voter1 got power + assert_ne!(pre_voter1_takeover_gauge_set, current_gauge_set); + + // Voter1 option is now most popular + assert_eq!( + current_gauge_set, + vec![ + ("voter1".to_string(), Uint128::new(900)), + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)) + ] + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // Mock voter 1 unstaking + suite + .app + .execute_contract( + Addr::unchecked(hook_caller), + gauge_contract.clone(), + &StakeChangedExecuteMsg::StakeChangeHook( + dao_hooks::stake::StakeChangedHookMsg::Unstake { + addr: Addr::unchecked(voter1), + amount: Uint128::new(1000), + }, + ), + &[], + ) + .unwrap(); + suite.next_block(); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let current_gauge_set = suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(); + + // Voter1 removed and so is the one thing they voted for + assert_eq!( + current_gauge_set, + Some(vec![ + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)) + ]) + ); +} + +#[test] +fn nft_staking_voting_power_change() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let hook_caller = "nft-staking-contract"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 1), (voter2, 2)]) + .with_core_balance((10000, "ujuno")) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module_custom_hook_caller( + voter1.to_string(), + hook_caller.to_string(), + None, + ) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(100))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 100, suite.current_time()), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::one())]); + + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let pre_voter1_takeover_gauge_set = + suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Voter one's option is least popular + assert_eq!( + pre_voter1_takeover_gauge_set, + vec![ + ("voter1".to_string(), Uint128::new(1)), + ("option2".to_string(), Uint128::new(1)), + ("option1".to_string(), Uint128::new(1)), + ] + ); + + // Mock voter 1 staking NFT + suite + .app + .execute_contract( + Addr::unchecked(hook_caller), + gauge_contract.clone(), + &NftStakeChangedExecuteMsg::NftStakeChangeHook(NftStakeChangedHookMsg::Stake { + addr: Addr::unchecked(voter1), + token_id: "1".to_string(), + }), + &[], + ) + .unwrap(); + + suite.next_block(); + + let current_gauge_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Currect selected set should be different than before voter1 got power + assert_ne!(pre_voter1_takeover_gauge_set, current_gauge_set); + + // Voter1 option is now most popular + assert_eq!( + current_gauge_set, + vec![ + ("voter1".to_string(), Uint128::new(2)), + ("option2".to_string(), Uint128::new(1)), + ("option1".to_string(), Uint128::new(1)) + ] + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // Mock voter1 unstaking 2 nfts + suite + .app + .execute_contract( + Addr::unchecked(hook_caller), + gauge_contract.clone(), + &NftStakeChangedExecuteMsg::NftStakeChangeHook(NftStakeChangedHookMsg::Unstake { + addr: Addr::unchecked(voter1), + token_ids: vec!["1".to_string(), "2".to_string()], + }), + &[], + ) + .unwrap(); + suite.next_block(); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let current_gauge_set = suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(); + + // Voter1 removed and so is the one thing they voted for + assert_eq!( + current_gauge_set, + Some(vec![ + ("option2".to_string(), Uint128::new(1)), + ("option1".to_string(), Uint128::new(1)) + ]) + ); +} diff --git a/contracts/gauges/gauge/src/state.rs b/contracts/gauges/gauge/src/state.rs new file mode 100644 index 000000000..a48c4fd20 --- /dev/null +++ b/contracts/gauges/gauge/src/state.rs @@ -0,0 +1,719 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Decimal, Deps, Env, Order, StdResult, Storage, Uint128}; +use cw_storage_plus::{Bound, Index, IndexList, IndexedMap, Item, Map, MultiIndex}; +use cw_utils::maybe_addr; + +use crate::msg::VoteInfo; + +/// Type alias for u64 to make the map types a bit more self-explanatory +pub type GaugeId = u64; + +pub const CONFIG: Item = Item::new("config"); +pub const GAUGES: Map = Map::new("gauges"); +const LAST_ID: Item = Item::new("last_id"); + +/// Get ID for gauge registration and increment value in storage +pub fn fetch_last_id(storage: &mut dyn Storage) -> StdResult { + let last_id = LAST_ID.load(storage).unwrap_or_default(); + LAST_ID.save(storage, &(last_id + 1u64))?; + Ok(last_id) +} + +/// This lets us find and update any vote given both voter and gauge. +/// It also lets us iterate over all votes by a given voter on all gauges +/// or by a given gauge id. This is needed when a voter weight changes +/// in order to update the guage. +pub fn votes() -> Votes<'static> { + Votes::new("votes", "votes__gaugeid") +} + +// settings for pagination +const MAX_LIMIT: u32 = 100; +const DEFAULT_LIMIT: u32 = 30; + +#[cw_serde] +pub struct Config { + /// Address of contract to that contains all voting powers (where we query) + pub voting_powers: Addr, + /// Addres that will call voting power change hooks (often same as voting power contract) + pub hook_caller: Addr, + /// Address that can add new gauges or stop them + pub owner: Addr, + /// Address of DAO core module resposible for instantiation and execution of messages + pub dao_core: Addr, +} + +#[cw_serde] +pub struct Gauge { + /// Descriptory label of gauge + pub title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: Addr, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set + pub min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, + /// True if the gauge is stopped + pub is_stopped: bool, + /// UNIX time (seconds) when next epoch can be executed. If < env.block.time then Execute can be called + pub next_epoch: u64, + /// The last set of options selected by the gauge, `None` before the first execution + pub last_executed_set: Option>, + /// Set this in migration if the gauge should be periodically reset + pub reset: Option, +} + +#[cw_serde] +pub struct Reset { + /// until the first reset, this is None - needed for 0-cost migration from current state + pub last: Option, + /// seconds between reset + pub reset_each: u64, + /// next time we can reset + pub next: u64, +} + +impl Gauge { + /// Returns `true` if the gauge is currently being reset + pub fn is_resetting(&self) -> bool { + self.reset + .as_ref() + .map(|r| r.last == Some(r.next)) + .unwrap_or_default() + } +} + +#[cw_serde] +pub struct WeightedVotes { + /// The gauge these votes are for + pub gauge_id: GaugeId, + /// The voting power behind the vote. + pub power: Uint128, + /// the user's votes for this gauge + pub votes: Vec, + /// Timestamp when vote was cast. + /// Allow `None` for 0-cost migration from current data + pub cast: Option, +} + +impl WeightedVotes { + /// Returns `true` if the vote is + pub fn is_expired(&self, gauge: &Gauge) -> bool { + // check if the vote is older than the last reset + match &gauge.reset { + Some(Reset { + last: Some(expired), + .. + }) => { + // votes with no timestamp are always considered too old once a reset happened + // (they are legacy votes pre-first reset) + self.cast.unwrap_or_default() < *expired + } + // everything is valid before the first reset (last = `None`) or if the gauge is not resettable + _ => false, + } + } +} + +impl Default for WeightedVotes { + fn default() -> Self { + WeightedVotes { + gauge_id: 0, + power: Uint128::zero(), + votes: vec![], + cast: None, + } + } +} + +#[cw_serde] +pub struct Vote { + /// Option voted for. + pub option: String, + /// The weight of the power given to this vote + pub weight: Decimal, +} + +struct VoteIndexes<'a> { + // Last type param defines the pk deserialization type + pub vote: MultiIndex<'a, GaugeId, WeightedVotes, (&'a Addr, GaugeId)>, +} + +impl<'a> IndexList for VoteIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + Box::new(std::iter::once(&self.vote as &dyn Index)) + } +} + +pub struct Votes<'a> { + // Votes are indexed by `(addr, gauge_id, weight)` triplet + votes: IndexedMap<'a, (&'a Addr, GaugeId), WeightedVotes, VoteIndexes<'a>>, +} + +impl<'a> Votes<'a> { + pub fn new(storage_key: &'a str, vote_subkey: &'a str) -> Self { + let indexes = VoteIndexes { + vote: MultiIndex::new(|_, vote| vote.gauge_id, storage_key, vote_subkey), + }; + let votes = IndexedMap::new(storage_key, indexes); + Self { votes } + } + + pub fn save( + &self, + storage: &mut dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + vote: &WeightedVotes, + ) -> StdResult<()> { + self.votes.save(storage, (voter, gauge_id), vote) + } + + pub fn set_votes( + &self, + storage: &mut dyn Storage, + env: &Env, + voter: &'a Addr, + gauge_id: GaugeId, + votes: Vec, + power: impl Into, + ) -> StdResult<()> { + let power = power.into(); + self.votes.save( + storage, + (voter, gauge_id), + &WeightedVotes { + gauge_id, + power, + votes, + cast: Some(env.block.time.seconds()), + }, + ) + } + + pub fn remove_votes( + &self, + storage: &mut dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult<()> { + self.votes.remove(storage, (voter, gauge_id)) + } + + pub fn load( + &self, + storage: &dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult { + self.votes.load(storage, (voter, gauge_id)) + } + + pub fn may_load( + &self, + storage: &dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult> { + self.votes.may_load(storage, (voter, gauge_id)) + } + + pub fn query_votes_by_voter( + &self, + deps: Deps, + voter_addr: &'a Addr, + start_after: Option, + limit: Option, + ) -> StdResult> { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + + self.votes + .prefix(voter_addr) + .range(deps.storage, start, None, Order::Ascending) + .map(|index| { + let (_, vote) = index?; + Ok(vote) + }) + .take(limit) + .collect() + } + + pub fn query_votes_by_gauge( + &self, + deps: Deps, + gauge_id: GaugeId, + 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(|a| Bound::exclusive((a, gauge_id))); + + let gauge = GAUGES.load(deps.storage, gauge_id)?; + + self.votes + .idx + .vote + .prefix(gauge_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .filter(|r| match r { + Ok((_, v)) => !v.is_expired(&gauge), // filter out expired votes + Err(_) => true, // keep the error + }) + .map(|r| { + let ((voter, _gauge), votes) = r?; + Ok(VoteInfo { + voter: voter.into_string(), + votes: votes.votes, + cast: votes.cast, + }) + }) + // NIT: collect and into_iter is a bit inefficient... guess it was too complex/confusing otherwise, so fine + .collect() + } +} + +/// Total amount of votes in all options, used to calculate min percentage. +pub const TOTAL_CAST: Map = Map::new("total_power"); + +/// Count how many points each option has per gauge +pub const TALLY: Map<(GaugeId, &str), u128> = Map::new("tally"); +/// Sorted index of options by points, separated by gauge - data field is a placeholder +pub const OPTION_BY_POINTS: Map<(GaugeId, u128, &str), u8> = Map::new("tally_points"); + +/// Updates the tally for one option. +/// The first time a user votes, they get `{old_vote: 0, new_vote: power}` +/// If they change options, call old option with `{old_vote: power, new_vote: 0}` and new option with `{old_vote: 0, new_vote: power}` +/// If a user changes power (member update hook), call existing option with `{old_vote: old_power, new_vote: new_power}` +pub fn update_tally( + storage: &mut dyn Storage, + gauge: GaugeId, + option: &str, + old_vote: u128, + new_vote: u128, +) -> StdResult<()> { + update_tallies(storage, gauge, vec![(option, old_vote, new_vote)]) +} + +/// Completely removes the given option from the tally. +pub fn remove_tally(storage: &mut dyn Storage, gauge: GaugeId, option: &str) -> StdResult<()> { + let old_vote = TALLY.may_load(storage, (gauge, option))?; + + // update main index + TALLY.remove(storage, (gauge, option)); + + if let Some(old_vote) = old_vote { + let total_cast = TOTAL_CAST.may_load(storage, gauge)?.unwrap_or_default(); + // update total cast + TOTAL_CAST.save(storage, gauge, &(total_cast - old_vote))?; + + // update sorted index + OPTION_BY_POINTS.remove(storage, (gauge, old_vote, option)); + } + + Ok(()) +} + +/// Updates the tally for one option. +/// The first time a user votes, they get `{old_vote: 0, new_vote: power}` +/// If they change options, call old option with `{old_vote: power, new_vote: 0}` and new option with `{old_vote: 0, new_vote: power}` +/// If a user changes power (member update hook), call existing option with `{old_vote: old_power, new_vote: new_power}` +pub fn update_tallies( + storage: &mut dyn Storage, + gauge: GaugeId, + // (option, old, new) + updates: Vec<(&str, u128, u128)>, +) -> StdResult<()> { + let mut old_votes = 0u128; + let mut new_votes = 0u128; + + for (option, old_vote, new_vote) in updates { + old_votes += old_vote; + new_votes += new_vote; + + // get old and new values + let old_count = TALLY.may_load(storage, (gauge, option))?; + let count = old_count.unwrap_or_default() + new_vote - old_vote; + + // update main index + TALLY.save(storage, (gauge, option), &count)?; + + // delete old secondary index (if any) + if let Some(old) = old_count { + OPTION_BY_POINTS.remove(storage, (gauge, old, option)); + } + // add new secondary index + OPTION_BY_POINTS.save(storage, (gauge, count, option), &1u8)?; + } + + // update total count + let total = TOTAL_CAST.may_load(storage, gauge)?.unwrap_or_default(); + let total = total + new_votes - old_votes; + TOTAL_CAST.save(storage, gauge, &total) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::Order; + + use cosmwasm_std::testing::{mock_dependencies, mock_env}; + + const GAUGE: u64 = 2; + + /// Let's keep them all the same length for less surprising iteration + const OPTION1: &str = "one"; + const OPTION2: &str = "two"; + // make sure it is alphabetically last + const OPTION3: &str = "zzz"; + + // demonstate how to call update tally and how to query the tallies, + // both by pk and by secondary index + #[test] + fn update_tally_initial_votes_work() { + let mut mock_deps = mock_dependencies(); + let deps = mock_deps.as_mut(); + + update_tally(deps.storage, GAUGE, OPTION1, 0, 250).unwrap(); + update_tally(deps.storage, GAUGE, OPTION2, 0, 400).unwrap(); + update_tally(deps.storage, GAUGE, OPTION3, 0, 100).unwrap(); + + // data in some other tally shouldn't mix with this gauge + update_tally(deps.storage, 17, OPTION3, 0, 55).unwrap(); + update_tally(deps.storage, 16, OPTION1, 0, 123).unwrap(); + + // get all options with primary index (ordered by string value of option) + let options: Vec<_> = TALLY + .prefix(GAUGE) + .range(deps.storage, None, None, Order::Ascending) + .collect::>>() + .unwrap(); + let expected = vec![ + (OPTION1.to_string(), 250u128), + (OPTION2.to_string(), 400u128), + (OPTION3.to_string(), 100u128), + ]; + assert_eq!(options, expected); + + // get them by secondary index, top to bottom + let options: Vec<_> = OPTION_BY_POINTS + .sub_prefix(GAUGE) + .keys(deps.storage, None, None, Order::Descending) + .collect::>>() + .unwrap(); + let expected = vec![ + (400u128, OPTION2.to_string()), + (250u128, OPTION1.to_string()), + (100u128, OPTION3.to_string()), + ]; + assert_eq!(options, expected); + + // total is properly set + let total = TOTAL_CAST.load(deps.storage, GAUGE).unwrap(); + assert_eq!(total, 750u128); + } + + fn to_vote_info(voter: &Addr, votes: &[Vote], cast: impl Into>) -> VoteInfo { + VoteInfo { + voter: voter.to_string(), + votes: votes.to_vec(), + cast: cast.into(), + } + } + + #[test] + fn votes_works() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let votes = votes(); + + // setup gauges + for gauge_id in 1..=3 { + GAUGES + .save( + &mut deps.storage, + gauge_id, + &Gauge { + title: "test".to_string(), + adapter: Addr::unchecked("gauge_adapter"), + epoch: 100, + min_percent_selected: None, + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: env.block.time.seconds(), + last_executed_set: None, + reset: None, + }, + ) + .unwrap(); + } + + let user1 = Addr::unchecked("user1"); + let votes1 = vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote1 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(3), + votes: votes1.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 1), &vote1) + .unwrap(); + + let user2 = Addr::unchecked("user2"); + let votes2 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(50), + }]; + let vote2 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(6), + votes: votes2.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user2, 1), &vote2) + .unwrap(); + + let user3 = Addr::unchecked("user3"); + let votes3 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(70), + }]; + let vote3 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(9), + votes: votes3.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user3, 1), &vote3) + .unwrap(); + + let votes4 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(75), + }]; + let vote4 = WeightedVotes { + gauge_id: 2, + power: Uint128::new(12), + votes: votes4, + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 2), &vote4) + .unwrap(); + + let votes5 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote5 = WeightedVotes { + gauge_id: 3, + power: Uint128::new(15), + votes: votes5, + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 3), &vote5) + .unwrap(); + + let vote_result = votes.votes.load(&deps.storage, (&user2, 1)).unwrap(); + assert_eq!(vote_result, vote2); + + let result = votes + .query_votes_by_gauge(deps.as_ref(), 1, None, None) + .unwrap(); + assert_eq!( + result, + vec![ + to_vote_info(&user1, &votes1, env.block.time.seconds()), + to_vote_info(&user2, &votes2, env.block.time.seconds()), + to_vote_info(&user3, &votes3, env.block.time.seconds()), + ] + ); + + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, None, None) + .unwrap(); + assert_eq!(result, vec![vote1, vote4, vote5]); + } + + #[test] + fn query_votes_by_gauge_paginated() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let votes = votes(); + + let gauge_id = 1; + + GAUGES + .save( + &mut deps.storage, + gauge_id, + &Gauge { + title: "test".to_string(), + adapter: Addr::unchecked("gauge_adapter"), + epoch: 100, + min_percent_selected: None, + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: env.block.time.seconds(), + last_executed_set: None, + reset: None, + }, + ) + .unwrap(); + + let user1 = Addr::unchecked("user1"); + let votes1 = vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote1 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(3), + votes: votes1.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 1), &vote1) + .unwrap(); + + let user2 = Addr::unchecked("user2"); + let votes2 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(50), + }]; + let vote2 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(6), + votes: votes2.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user2, 1), &vote2) + .unwrap(); + + let user3 = Addr::unchecked("user3"); + let votes3 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(70), + }]; + let vote3 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(9), + votes: votes3.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user3, 1), &vote3) + .unwrap(); + + // limit to 2 results + let result = votes + .query_votes_by_gauge(deps.as_ref(), gauge_id, None, Some(2)) + .unwrap(); + assert_eq!( + result, + vec![ + to_vote_info(&user1, &votes1, env.block.time.seconds()), + to_vote_info(&user2, &votes2, env.block.time.seconds()) + ] + ); + + // start from second user (start_after user1) + let result = votes + .query_votes_by_gauge(deps.as_ref(), gauge_id, Some(user1.to_string()), None) + .unwrap(); + assert_eq!( + result, + vec![ + to_vote_info(&user2, &votes2, env.block.time.seconds()), + to_vote_info(&user3, &votes3, env.block.time.seconds()) + ] + ); + } + + #[test] + fn query_votes_by_user_paginated() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let votes = votes(); + let user1 = Addr::unchecked("user1"); + + let vote1 = WeightedVotes { + gauge_id: 2, + power: Uint128::new(3), + votes: vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }], + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 2), &vote1) + .unwrap(); + + let vote2 = WeightedVotes { + gauge_id: 3, + power: Uint128::new(6), + votes: vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }], + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 3), &vote2) + .unwrap(); + + let vote3 = WeightedVotes { + gauge_id: 4, + power: Uint128::new(9), + votes: vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }], + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 4), &vote3) + .unwrap(); + + // limit to 2 results + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, None, Some(2)) + .unwrap(); + assert_eq!(result, vec![vote1, vote2.clone()]); + + // start from second user (start_after gauge_id 2) + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, Some(2), None) + .unwrap(); + assert_eq!(result, vec![vote2, vote3]); + } +}