From 45a3bdbe49a989eb4a31b095405c28733797c760 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 7 Oct 2024 16:51:28 -0400 Subject: [PATCH 01/24] created contract scaffold --- Cargo.lock | 31 ++++++++ Cargo.toml | 2 + contracts/README.md | 8 +- .../delegation/dao-vote-delegation/Cargo.toml | 45 +++++++++++ .../delegation/dao-vote-delegation/README.md | 54 +++++++++++++ .../dao-vote-delegation/examples/schema.rs | 11 +++ .../dao-vote-delegation/src/error.rs | 30 +++++++ .../delegation/dao-vote-delegation/src/lib.rs | 11 +++ .../delegation/dao-vote-delegation/src/msg.rs | 79 +++++++++++++++++++ .../dao-vote-delegation/src/state.rs | 47 +++++++++++ 10 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 contracts/delegation/dao-vote-delegation/Cargo.toml create mode 100644 contracts/delegation/dao-vote-delegation/README.md create mode 100644 contracts/delegation/dao-vote-delegation/examples/schema.rs create mode 100644 contracts/delegation/dao-vote-delegation/src/error.rs create mode 100644 contracts/delegation/dao-vote-delegation/src/lib.rs create mode 100644 contracts/delegation/dao-vote-delegation/src/msg.rs create mode 100644 contracts/delegation/dao-vote-delegation/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 0f101d9b4..c1327631a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2365,6 +2365,37 @@ dependencies = [ "stake-cw20-reward-distributor", ] +[[package]] +name = "dao-vote-delegation" +version = "2.6.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw20 1.1.2", + "cw20-base 1.1.2", + "cw20-stake 2.6.0", + "cw4 1.1.2", + "cw4-group 1.1.2", + "cw721-base 0.18.0", + "dao-hooks 2.6.0", + "dao-interface 2.6.0", + "dao-testing", + "dao-voting 2.6.0", + "dao-voting-cw20-staked", + "dao-voting-cw4 2.6.0", + "dao-voting-cw721-staked", + "dao-voting-token-staked", + "semver", + "thiserror", +] + [[package]] name = "dao-voting" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 72f0b9baa..e4c79aa0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ exclude = ["ci/configs/", "wasmvm/libwasmvm"] members = [ "contracts/dao-dao-core", "contracts/distribution/*", + "contracts/delegation/*", "contracts/external/*", "contracts/proposal/*", "contracts/pre-propose/*", @@ -122,6 +123,7 @@ dao-proposal-sudo = { path = "./contracts/test/dao-proposal-sudo", version = "2. dao-rewards-distributor = { path = "./contracts/distribution/dao-rewards-distributor", version = "2.6.0" } dao-test-custom-factory = { path = "./contracts/test/dao-test-custom-factory", version = "2.6.0" } dao-testing = { path = "./packages/dao-testing", version = "2.6.0" } +dao-vote-delegation = { path = "./contracts/delegation/dao-vote-delegation", version = "2.6.0" } dao-voting = { path = "./packages/dao-voting", version = "2.6.0" } dao-voting-cw20-balance = { path = "./contracts/test/dao-voting-cw20-balance", version = "2.6.0" } dao-voting-cw20-staked = { path = "./contracts/voting/dao-voting-cw20-staked", version = "2.6.0" } diff --git a/contracts/README.md b/contracts/README.md index 88c0f6e83..be8f48509 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,12 +1,12 @@ # DAO Contracts - `dao-dao-core` - the core module for DAOs. -- `external` - contracts used by DAOs that are not part of a DAO - module. +- `delegation` - delegation modules. +- `distribution` - token distribution modules. +- `external` - contracts used by DAOs that are not part of a DAO module. - `pre-propose` - pre-propose modules. - `proposal` - proposal modules. +- `staking` - cw20 staking functionality and a staking rewards system. - `voting` - voting modules. -- `staking` - cw20 staking functionality and a staking rewards - system. These contracts are used by [Wasmswap](https://github.com/Wasmswap) as well as DAO DAO. For a description of each module type, see [our wiki](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design). diff --git a/contracts/delegation/dao-vote-delegation/Cargo.toml b/contracts/delegation/dao-vote-delegation/Cargo.toml new file mode 100644 index 000000000..edb2b4e98 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "dao-vote-delegation" +authors = ["Noah "] +description = "Manages delegation of voting power for DAOs." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw2 = { workspace = true } +cw4 = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw-controllers = { workspace = true } +cw-ownable = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +dao-hooks = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +semver = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = { workspace = true } +cw20-stake = { workspace = true, features = ["library"] } +cw4-group = { workspace = true, features = ["library"] } +cw721-base = { workspace = true, features = ["library"] } +dao-voting-cw20-staked = { workspace = true, features = ["library"] } +dao-voting-cw4 = { workspace = true, features = ["library"] } +dao-voting-token-staked = { workspace = true, features = ["library"] } +dao-voting-cw721-staked = { workspace = true, features = ["library"] } +dao-testing = { workspace = true } diff --git a/contracts/delegation/dao-vote-delegation/README.md b/contracts/delegation/dao-vote-delegation/README.md new file mode 100644 index 000000000..f73912c64 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/README.md @@ -0,0 +1,54 @@ +# DAO Vote Delegation + +[![dao-vote-delegation on +crates.io](https://img.shields.io/crates/v/dao-vote-delegation.svg?logo=rust)](https://crates.io/crates/dao-vote-delegation) +[![docs.rs](https://img.shields.io/docsrs/dao-vote-delegation?logo=docsdotrs)](https://docs.rs/dao-vote-delegation/latest/dao_vote_delegation/) + +The `dao-vote-delegation` contract allows members of a DAO to delegate their +voting power to other members of the DAO who have registered as delegates. It +works in conjunction with voting and proposal modules, as well as the rewards +distributor, to offer a comprehensive delegation system for DAOs that supports +the following features: + +- Fractional delegation of voting power on a per-proposal-module basis. +- Overridable delegate votes that can be overridden on a per-proposal basis by + the delegator +- Delegate reward commission. + +## Instantiation and Setup + +This contract must be instantiated by the DAO. + +### Hooks + +After instantiating the contract, it is VITAL to set up the required hooks for +it to work. To compute delegate voting power correctly, this contract needs to +know about both voting power changes and votes cast on proposals as soon as they +happen. + +This can be achieved using the `add_hook` method on voting/staking contracts +that support voting power changes, such as: + +- `cw4-group` +- `dao-voting-cw721-staked` +- `dao-voting-token-staked` +- `cw20-stake` + +For proposal modules, the corresponding hook is `add_vote_hook`: + +- `dao-proposal-single` +- `dao-proposal-multiple` +- `dao-proposal-condorcet` + +## Design Decisions + +### Fractional Delegation via Percentages + +In order to support fractional delegation, users assign a percentage of voting +power to each delegate. Percentages are used instead of choosing an absolute +amount of voting power (e.g. staked tokens) since voting power can change +independently of delegation. If an absolute amount were used, and a user who had +delegated all of their voting power to a few different delegates then unstaked +half of their tokens, there is no clear way to resolve what their new +delegations are. Using percentages instead allows voting power and delegation to +be decided independently. diff --git a/contracts/delegation/dao-vote-delegation/examples/schema.rs b/contracts/delegation/dao-vote-delegation/examples/schema.rs new file mode 100644 index 000000000..19120c210 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_vote_delegation::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/delegation/dao-vote-delegation/src/error.rs b/contracts/delegation/dao-vote-delegation/src/error.rs new file mode 100644 index 000000000..cfcf562c6 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/error.rs @@ -0,0 +1,30 @@ +use cosmwasm_std::{DivideByZeroError, OverflowError, StdError}; +use cw_utils::PaymentError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Ownable(#[from] cw_ownable::OwnershipError), + + #[error(transparent)] + Overflow(#[from] OverflowError), + + #[error(transparent)] + DivideByZero(#[from] DivideByZeroError), + + #[error(transparent)] + Payment(#[from] PaymentError), + + #[error("semver parsing error: {0}")] + SemVer(String), +} + +impl From for ContractError { + fn from(err: semver::Error) -> Self { + Self::SemVer(err.to_string()) + } +} diff --git a/contracts/delegation/dao-vote-delegation/src/lib.rs b/contracts/delegation/dao-vote-delegation/src/lib.rs new file mode 100644 index 000000000..d4a73c5be --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/delegation/dao-vote-delegation/src/msg.rs b/contracts/delegation/dao-vote-delegation/src/msg.rs new file mode 100644 index 000000000..0222ccec4 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/msg.rs @@ -0,0 +1,79 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Decimal}; +use cw4::MemberChangedHookMsg; +use cw_ownable::cw_ownable_execute; +use cw_utils::Duration; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; +use dao_interface::voting::InfoResponse; + +pub use cw_ownable::Ownership; + +use crate::state::Delegation; + +#[cw_serde] +pub struct InstantiateMsg { + /// The DAO. If not provided, the instantiator is used. + pub dao: Option, + /// the maximum percent of voting power that a single delegate can wield. + /// they can be delegated any amount of voting power—this cap is only + /// applied when casting votes. + pub vp_cap_percent: Option, + /// the duration a delegation is valid for, after which it must be renewed + /// by the delegator. + pub delegation_validity: Option, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + /// Called when a member is added or removed + /// to a cw4-groups or cw721-roles contract. + MemberChangedHook(MemberChangedHookMsg), + /// Called when NFTs are staked or unstaked. + NftStakeChangeHook(NftStakeChangedHookMsg), + /// Called when tokens are staked or unstaked. + StakeChangeHook(StakeChangedHookMsg), + /// updates the configuration of the delegation system + UpdateConfig { + /// the maximum percent of voting power that a single delegate can + /// wield. they can be delegated any amount of voting power—this cap is + /// only applied when casting votes. + vp_cap_percent: Option, + /// the duration a delegation is valid for, after which it must be + /// renewed by the delegator. + delegation_validity: Option, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns contract version info + #[returns(InfoResponse)] + Info {}, + /// Returns information about the ownership of this contract. + #[returns(Ownership)] + Ownership {}, + /// Returns the delegations by a delegator. + #[returns(DelegationsResponse)] + DelegatorDelegations { + delegator: String, + start_after: Option, + limit: Option, + }, + /// Returns the delegations to a delegate. + #[returns(DelegationsResponse)] + DelegateDelegations { + delegate: String, + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct DelegationsResponse { + pub delegations: Vec, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/delegation/dao-vote-delegation/src/state.rs b/contracts/delegation/dao-vote-delegation/src/state.rs new file mode 100644 index 000000000..977efaa75 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/state.rs @@ -0,0 +1,47 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Decimal, Uint128}; +use cw20::Expiration; +use cw_storage_plus::{Item, Map, SnapshotMap, Strategy}; +use cw_utils::Duration; + +/// the configuration of the delegation system. +pub const CONFIG: Item = Item::new("config"); + +/// the DAO this delegation system is connected to. +pub const DAO: Item = Item::new("dao"); + +/// the VP delegated to a delegate that has not yet been used in votes cast by +/// delegators in a specific proposal. +pub const UNVOTED_DELEGATED_VP: Map<(&Addr, u64), Uint128> = Map::new("udvp"); + +/// the VP delegated to a delegate by height. +pub const DELEGATED_VP: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "dvp", + "dvp__checkpoints", + "dvp__changelog", + Strategy::EveryBlock, +); + +#[cw_serde] +pub struct Config { + /// the maximum percent of voting power that a single delegate can wield. + /// they can be delegated any amount of voting power—this cap is only + /// applied when casting votes. + pub vp_cap_percent: Option, + /// the duration a delegation is valid for, after which it must be renewed + /// by the delegator. + pub delegation_validity: Option, +} + +#[cw_serde] +pub struct Delegation { + /// the delegator. + pub delegator: Addr, + /// the delegate that can vote on behalf of the delegator. + pub delegate: Addr, + /// the percent of the delegator's voting power that is delegated to the + /// delegate. + pub percent: Decimal, + /// when the delegation expires. + pub expiration: Expiration, +} From c52a9aed5354a437c1ccfc38b112600795d548d9 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 10 Oct 2024 12:02:27 -0400 Subject: [PATCH 02/24] built first pass cw-snapshot-vector-map lib --- Cargo.lock | 12 + Cargo.toml | 1 + packages/cw-snapshot-vector-map/Cargo.toml | 16 ++ packages/cw-snapshot-vector-map/README.md | 125 ++++++++++ packages/cw-snapshot-vector-map/src/lib.rs | 207 ++++++++++++++++ packages/cw-snapshot-vector-map/src/tests.rs | 233 +++++++++++++++++++ 6 files changed, 594 insertions(+) create mode 100644 packages/cw-snapshot-vector-map/Cargo.toml create mode 100644 packages/cw-snapshot-vector-map/README.md create mode 100644 packages/cw-snapshot-vector-map/src/lib.rs create mode 100644 packages/cw-snapshot-vector-map/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index c1327631a..3c3bdeb97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1010,6 +1010,18 @@ dependencies = [ "vote-hooks", ] +[[package]] +name = "cw-snapshot-vector-map" +version = "2.6.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw20 1.1.2", + "serde", +] + [[package]] name = "cw-stake-tracker" version = "2.6.0" diff --git a/Cargo.toml b/Cargo.toml index e4c79aa0a..43336a8f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,7 @@ cw-fund-distributor = { path = "./contracts/distribution/cw-fund-distributor", v cw-hooks = { path = "./packages/cw-hooks", version = "2.6.0" } cw-paginate-storage = { path = "./packages/cw-paginate-storage", version = "2.6.0" } cw-payroll-factory = { path = "./contracts/external/cw-payroll-factory", version = "2.6.0" } +cw-snapshot-vector-map = { path = "./packages/cw-snapshot-vector-map", version = "2.6.0" } cw-stake-tracker = { path = "./packages/cw-stake-tracker", version = "2.6.0" } cw-token-swap = { path = "./contracts/external/cw-token-swap", version = "2.6.0" } cw-tokenfactory-issuer = { path = "./contracts/external/cw-tokenfactory-issuer", version = "2.6.0", default-features = false } diff --git a/packages/cw-snapshot-vector-map/Cargo.toml b/packages/cw-snapshot-vector-map/Cargo.toml new file mode 100644 index 000000000..1388e071b --- /dev/null +++ b/packages/cw-snapshot-vector-map/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cw-snapshot-vector-map" +authors = ["noah "] +description = "A CosmWasm vector map that allows reading the items that existed at any height in the past." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw20 = { workspace = true } +serde = { workspace = true } diff --git a/packages/cw-snapshot-vector-map/README.md b/packages/cw-snapshot-vector-map/README.md new file mode 100644 index 000000000..bcb7c8d43 --- /dev/null +++ b/packages/cw-snapshot-vector-map/README.md @@ -0,0 +1,125 @@ +# CW Snapshot Vector Map + +A snapshot vector map maps keys to vectors of items, where the vectors' sets of +items can be read at any height in the past. Items can be given an expiration, +after which they automatically disappear from the vector. This minimizes +redundant storage while allowing for efficient querying of historical data on a +changing set of items. + +Because this uses a `SnapshotMap` under the hood, it's important to note that +all pushes and removals occuring on a given block will be reflected on the +following block. Since expirations are computed relative to the block they are +pushed at, an expiration of 1 block means the item will never appear in the +vector. More concretely, if an item is pushed at block `n` with an expiration of +`m`, it will be included in the vector when queried at block `n + 1` up to `n + +m - 1`. The vector at block `n + m` will no longer include the item. + +## Performance + +All operations (push/remove/load) run in O(n). When pushing/removing, `n` refers +to the number of items in the most recent version of the vector. When loading, +`n` refers to the number of items in the vector at the given block. + +Storage is optimized by only storing each pushed item once, referencing them in +snapshots by numeric IDs that are much more compact. IDs are duplicated when the +vector is changed, while items are never duplicated. + +The default `load` function can paginate item loading, but it first requires +loading the entire set of IDs from storage. Thus there is some maximum number of +items that can be stored based on gas limits and storage costs. However, this +capacity is greatly increased by snapshotting IDs rather than items directly. + +## Limitations + +This data structure is only designed to be updated in the present and read in +the past/present. More concretely, items can only be pushed or removed at a +block greater than or equal to the last block at which an item was pushed or +removed. + +Since all IDs must be loaded from storage before paginating item loading, there +is a maximum number of items that can be stored based on gas limits and storage +costs. This will vary by chain configuration but is likely quite high due to the +compact ID storage. + +## Example + +```rust +use cosmwasm_std::{testing::mock_dependencies, Addr, BlockInfo, Timestamp}; +use cw20::Expiration; +use cw_utils::Duration; +use cw_snapshot_vector_map::{LoadedItem, SnapshotVectorMap}; + +macro_rules! b { + ($x:expr) => { + &BlockInfo { + chain_id: "CHAIN".to_string(), + height: $x, + time: Timestamp::from_seconds($x), + } + }; +} + +let storage = &mut mock_dependencies().storage; +let svm: SnapshotVectorMap = SnapshotVectorMap::new( + "svm__items", + "svm__next_ids", + "svm__active", + "svm__active__checkpoints", + "svm__active__changelog", +); +let key = Addr::unchecked("leaf"); +let first = "first".to_string(); +let second = "second".to_string(); + +// store the first item at block 1, expiring in 10 blocks (at block 11) +svm.push(storage, &key, &first, b!(1), Some(Duration::Height(10))).unwrap(); + +// store the second item at block 5, which does not expire +svm.push(storage, &key, &second, b!(5), None).unwrap(); + +// remove the second item (ID: 1) at height 15 +svm.remove(storage, &key, 1, b!(15)).unwrap(); + +// the vector at block 3 should contain only the first item +assert_eq!( + svm.load_all(storage, &key, b!(3)).unwrap(), + vec![LoadedItem { + id: 0, + item: first.clone(), + expiration: Some(Expiration::AtHeight(11)), + }] +); + +// the vector at block 7 should contain both items +assert_eq!( + svm.load_all(storage, &key, b!(7)).unwrap(), + vec![ + LoadedItem { + id: 0, + item: first.clone(), + expiration: Some(Expiration::AtHeight(11)), + }, + LoadedItem { + id: 1, + item: second.clone(), + expiration: None, + } + ] +); + +// the vector at block 12 should contain only the first item +assert_eq!( + svm.load_all(storage, &key, b!(12)).unwrap(), + vec![LoadedItem { + id: 1, + item: second.clone(), + expiration: None, + }] +); + +// the vector at block 17 should contain nothing +assert_eq!( + svm.load_all(storage, &key, b!(17)).unwrap(), + vec![] +); +``` diff --git a/packages/cw-snapshot-vector-map/src/lib.rs b/packages/cw-snapshot-vector-map/src/lib.rs new file mode 100644 index 000000000..4ac228df1 --- /dev/null +++ b/packages/cw-snapshot-vector-map/src/lib.rs @@ -0,0 +1,207 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +use cw20::Expiration; +use cw_utils::Duration; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use cosmwasm_std::{BlockInfo, StdResult, Storage}; +use cw_storage_plus::{KeyDeserialize, Map, Prefixer, PrimaryKey, SnapshotMap, Strategy}; + +/// Map to a vector that allows reading the subset of items that existed at a +/// specific height in the past based on when items were added, removed, and +/// expired. +pub struct SnapshotVectorMap<'a, K, V> { + /// All items for a key, indexed by ID. + items: Map<'a, &'a (K, u64), V>, + /// The next item ID to use per-key. + next_ids: Map<'a, K, u64>, + /// The IDs of the items that are active for a key at a given height, and + /// optionally when they expire. + active: SnapshotMap<'a, K, Vec<(u64, Option)>>, +} + +/// A loaded item from the vector, including its ID and expiration. +#[derive(Debug, Clone, PartialEq)] +pub struct LoadedItem { + /// The ID of the item within the vector, which can be used to update or + /// remove it. + pub id: u64, + /// The item. + pub item: V, + /// When the item expires, if set. + pub expiration: Option, +} + +impl<'a, K, V> SnapshotVectorMap<'a, K, V> { + /// Creates a new [`SnapshotVectorMap`] with the given storage keys. + /// + /// Example: + /// + /// ```rust + /// use cw_snapshot_vector_map::SnapshotVectorMap; + /// + /// SnapshotVectorMap::<&[u8], &str>::new( + /// "data__items", + /// "data__next_ids", + /// "data__active", + /// "data__active__checkpoints", + /// "data__active__changelog", + /// ); + /// ``` + pub const fn new( + items_key: &'static str, + next_ids_key: &'static str, + active_key: &'static str, + active_checkpoints_key: &'static str, + active_changelog_key: &'static str, + ) -> Self { + SnapshotVectorMap { + items: Map::new(items_key), + next_ids: Map::new(next_ids_key), + active: SnapshotMap::new( + active_key, + active_checkpoints_key, + active_changelog_key, + Strategy::EveryBlock, + ), + } + } +} + +impl<'a, K, V> SnapshotVectorMap<'a, K, V> +where + // values can be serialized and deserialized + V: Serialize + DeserializeOwned, + // keys can be primary keys, cloned, deserialized, and prefixed + K: Clone + KeyDeserialize + Prefixer<'a> + PrimaryKey<'a>, + // &(key, ID) is a key in a map + for<'b> &'b (K, u64): PrimaryKey<'b>, +{ + /// Adds an item to the vector at the current block, optionally expiring in + /// the future, returning the ID of the new item. This block should be + /// greater than or equal to the blocks all previous items were + /// added/removed at. Pushing to the past will lead to incorrect behavior. + pub fn push( + &self, + store: &mut dyn Storage, + k: &K, + data: &V, + block: &BlockInfo, + expire_in: Option, + ) -> StdResult { + // get next ID for the key, defaulting to 0 + let next_id = self + .next_ids + .may_load(store, k.clone())? + .unwrap_or_default(); + + // add item to the list of all items for the key + self.items.save(store, &(k.clone(), next_id), data)?; + + // get active list for the key + let mut active = self.active.may_load(store, k.clone())?.unwrap_or_default(); + + // remove expired items + active.retain(|(_, expiration)| { + expiration.map_or(true, |expiration| !expiration.is_expired(block)) + }); + + // add new item and save list + active.push((next_id, expire_in.map(|d| d.after(block)))); + + // save the new list + self.active.save(store, k.clone(), &active, block.height)?; + + // update next ID + self.next_ids.save(store, k.clone(), &(next_id + 1))?; + + Ok(next_id) + } + + /// Removes an item from the vector by ID and returns it. The block should + /// be greater than or equal to the blocks all previous items were + /// added/removed at. Removing from the past will lead to incorrect + /// behavior. + pub fn remove( + &self, + store: &mut dyn Storage, + k: &K, + id: u64, + block: &BlockInfo, + ) -> StdResult { + // get active list for the key + let mut active = self.active.may_load(store, k.clone())?.unwrap_or_default(); + + // remove item and any expired items + active.retain(|(active_id, expiration)| { + active_id != &id && expiration.map_or(true, |expiration| !expiration.is_expired(block)) + }); + + // save the new list + self.active.save(store, k.clone(), &active, block.height)?; + + // load and return the item + self.load_item(store, k, id) + } + + /// Loads paged items at the given block that are not expired. + pub fn load( + &self, + store: &dyn Storage, + k: &K, + block: &BlockInfo, + limit: Option, + offset: Option, + ) -> StdResult>> { + let offset = offset.unwrap_or_default() as usize; + let limit = limit.unwrap_or(u64::MAX) as usize; + + let active_ids = self + .active + .may_load_at_height(store, k.clone(), block.height)? + .unwrap_or_default(); + + // load paged items, skipping expired ones + let items = active_ids + .iter() + .filter(|(_, expiration)| expiration.map_or(true, |exp| !exp.is_expired(block))) + .skip(offset) + .take(limit) + .map(|(id, expiration)| -> StdResult> { + let item = self.load_item(store, k, *id)?; + Ok(LoadedItem { + id: *id, + item, + expiration: *expiration, + }) + }) + .collect::>>()?; + + Ok(items) + } + + /// Loads all items at the given block that are not expired. + pub fn load_all( + &self, + store: &dyn Storage, + k: &K, + block: &BlockInfo, + ) -> StdResult>> { + self.load(store, k, block, None, None) + } + + /// Loads an item from the vector by ID. + pub fn load_item(&self, store: &dyn Storage, k: &K, id: u64) -> StdResult { + let item = self.items.load(store, &(k.clone(), id))?; + Ok(item) + } + + /// Loads an item from the vector by ID, if it exists. + pub fn may_load_item(&self, store: &dyn Storage, k: &K, id: u64) -> StdResult> { + self.items.may_load(store, &(k.clone(), id)) + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/cw-snapshot-vector-map/src/tests.rs b/packages/cw-snapshot-vector-map/src/tests.rs new file mode 100644 index 000000000..79e1978aa --- /dev/null +++ b/packages/cw-snapshot-vector-map/src/tests.rs @@ -0,0 +1,233 @@ +use cosmwasm_std::{testing::mock_dependencies, Addr, BlockInfo, Timestamp}; +use cw20::Expiration; +use cw_utils::Duration; + +use crate::{LoadedItem, SnapshotVectorMap}; + +macro_rules! b { + ($x:expr) => { + &BlockInfo { + chain_id: "".to_string(), + height: $x, + time: Timestamp::from_seconds($x), + } + }; +} + +#[test] +fn test_basic() { + let storage = &mut mock_dependencies().storage; + let svm: SnapshotVectorMap = SnapshotVectorMap::new( + "svm__items", + "svm__next_ids", + "svm__active", + "svm__active__checkpoints", + "svm__active__changelog", + ); + let k1 = &Addr::unchecked("haon"); + let k2 = &Addr::unchecked("ekez"); + + // add 1, 2, 3 to k1 at corresponding blocks + svm.push(storage, k1, &1, b!(1), None).unwrap(); + svm.push(storage, k1, &2, b!(2), None).unwrap(); + svm.push(storage, k1, &3, b!(3), None).unwrap(); + + // add 1, 3 to k2 at corresponding blocks + svm.push(storage, k2, &1, b!(1), None).unwrap(); + svm.push(storage, k2, &3, b!(3), None).unwrap(); + + // items update one block later + let items1_b2 = svm.load_all(storage, k1, b!(2)).unwrap(); + assert_eq!( + items1_b2, + vec![LoadedItem { + id: 0, + item: 1, + expiration: None, + }] + ); + + // items update one block later + let items1_b4 = svm.load_all(storage, k1, b!(4)).unwrap(); + assert_eq!( + items1_b4, + vec![ + LoadedItem { + id: 0, + item: 1, + expiration: None, + }, + LoadedItem { + id: 1, + item: 2, + expiration: None, + }, + LoadedItem { + id: 2, + item: 3, + expiration: None, + } + ] + ); + + // items update one block later + let items2_b3 = svm.load_all(storage, k2, b!(3)).unwrap(); + assert_eq!( + items2_b3, + vec![LoadedItem { + id: 0, + item: 1, + expiration: None, + }] + ); + + // remove item 2 (ID 1) from k1 at block 4 + svm.remove(storage, k1, 1, b!(4)).unwrap(); + + // items update one block later + let items1_b5 = svm.load_all(storage, k1, b!(5)).unwrap(); + assert_eq!( + items1_b5, + vec![ + LoadedItem { + id: 0, + item: 1, + expiration: None, + }, + LoadedItem { + id: 2, + item: 3, + expiration: None, + } + ] + ); +} + +#[test] +fn test_expiration() { + let storage = &mut mock_dependencies().storage; + let svm: SnapshotVectorMap = SnapshotVectorMap::new( + "svm__items", + "svm__next_ids", + "svm__active", + "svm__active__checkpoints", + "svm__active__changelog", + ); + let k1 = &Addr::unchecked("haon"); + + svm.push(storage, k1, &1, b!(1), Some(Duration::Height(3))) + .unwrap(); + svm.push(storage, k1, &4, b!(4), None).unwrap(); + + // items update one block later + let items1_b2 = svm.load_all(storage, k1, b!(2)).unwrap(); + assert_eq!( + items1_b2, + vec![LoadedItem { + id: 0, + item: 1, + expiration: Some(Expiration::AtHeight(4)), + }] + ); + + // not expired yet + let items1_b3 = svm.load_all(storage, k1, b!(3)).unwrap(); + assert_eq!( + items1_b3, + vec![LoadedItem { + id: 0, + item: 1, + expiration: Some(Expiration::AtHeight(4)), + }] + ); + + // expired: + // load returns nothing + let items1_b4 = svm.load_all(storage, k1, b!(4)).unwrap(); + assert_eq!(items1_b4, vec![]); + // but vector still has item since the list hasn't been updated + let active = svm + .active + .may_load_at_height(storage, k1.clone(), 4) + .unwrap(); + assert_eq!(active, Some(vec![(0, Some(Expiration::AtHeight(4)))])); + + // new item exists now + let items1_b5 = svm.load_all(storage, k1, b!(5)).unwrap(); + assert_eq!( + items1_b5, + vec![LoadedItem { + id: 1, + item: 4, + expiration: None, + }] + ); + + // add item that will expire + svm.push(storage, k1, &5, b!(5), Some(Duration::Height(3))) + .unwrap(); + + let items1_b6 = svm.load_all(storage, k1, b!(6)).unwrap(); + assert_eq!( + items1_b6, + vec![ + LoadedItem { + id: 1, + item: 4, + expiration: None + }, + LoadedItem { + id: 2, + item: 5, + expiration: Some(Expiration::AtHeight(8)), + } + ] + ); + + // removing first item at block 8 should expire the second item as well + svm.remove(storage, k1, 1, b!(8)).unwrap(); + + // load returns nothing (items update one block later) + let items1_b9 = svm.load_all(storage, k1, b!(9)).unwrap(); + assert_eq!(items1_b9, vec![]); + // and vector is empty since the remove updated the list + let active = svm + .active + .may_load_at_height(storage, k1.clone(), 9) + .unwrap(); + assert_eq!(active, Some(vec![])); + + // add item that will expire + svm.push(storage, k1, &9, b!(9), Some(Duration::Height(2))) + .unwrap(); + + let items1_b10 = svm.load_all(storage, k1, b!(10)).unwrap(); + assert_eq!( + items1_b10, + vec![LoadedItem { + id: 3, + item: 9, + expiration: Some(Expiration::AtHeight(11)) + }] + ); + + // push item at block 11, which should expire the existing item + svm.push(storage, k1, &11, b!(11), None).unwrap(); + + // load returns just the pushed item + let items1_b12 = svm.load_all(storage, k1, b!(12)).unwrap(); + assert_eq!( + items1_b12, + vec![LoadedItem { + id: 4, + item: 11, + expiration: None, + }] + ); + // and vector only contains the pushed item since remove updated the list + let active = svm + .active + .may_load_at_height(storage, k1.clone(), 12) + .unwrap(); + assert_eq!(active, Some(vec![(4, None)])); +} From 63f2c1614d5fba89fc64af6969471b7accefaa52 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 11 Oct 2024 16:16:27 -0400 Subject: [PATCH 03/24] only use block heights in cw-snapshot-vector-map --- Cargo.lock | 3 - packages/cw-snapshot-vector-map/Cargo.toml | 3 - packages/cw-snapshot-vector-map/README.md | 35 +++------ packages/cw-snapshot-vector-map/src/lib.rs | 50 +++++++------ packages/cw-snapshot-vector-map/src/tests.rs | 75 ++++++++------------ 5 files changed, 64 insertions(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c3bdeb97..b5b8ace58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,11 +1014,8 @@ dependencies = [ name = "cw-snapshot-vector-map" version = "2.6.0" dependencies = [ - "cosmwasm-schema", "cosmwasm-std", "cw-storage-plus 1.2.0", - "cw-utils 1.0.3", - "cw20 1.1.2", "serde", ] diff --git a/packages/cw-snapshot-vector-map/Cargo.toml b/packages/cw-snapshot-vector-map/Cargo.toml index 1388e071b..5df0f06fa 100644 --- a/packages/cw-snapshot-vector-map/Cargo.toml +++ b/packages/cw-snapshot-vector-map/Cargo.toml @@ -8,9 +8,6 @@ repository = { workspace = true } version = { workspace = true } [dependencies] -cosmwasm-schema = { workspace = true } cosmwasm-std = { workspace = true } cw-storage-plus = { workspace = true } -cw-utils = { workspace = true } -cw20 = { workspace = true } serde = { workspace = true } diff --git a/packages/cw-snapshot-vector-map/README.md b/packages/cw-snapshot-vector-map/README.md index bcb7c8d43..b00d96ed7 100644 --- a/packages/cw-snapshot-vector-map/README.md +++ b/packages/cw-snapshot-vector-map/README.md @@ -44,21 +44,9 @@ compact ID storage. ## Example ```rust -use cosmwasm_std::{testing::mock_dependencies, Addr, BlockInfo, Timestamp}; -use cw20::Expiration; -use cw_utils::Duration; +use cosmwasm_std::{testing::mock_dependencies, Addr}; use cw_snapshot_vector_map::{LoadedItem, SnapshotVectorMap}; -macro_rules! b { - ($x:expr) => { - &BlockInfo { - chain_id: "CHAIN".to_string(), - height: $x, - time: Timestamp::from_seconds($x), - } - }; -} - let storage = &mut mock_dependencies().storage; let svm: SnapshotVectorMap = SnapshotVectorMap::new( "svm__items", @@ -72,32 +60,32 @@ let first = "first".to_string(); let second = "second".to_string(); // store the first item at block 1, expiring in 10 blocks (at block 11) -svm.push(storage, &key, &first, b!(1), Some(Duration::Height(10))).unwrap(); +svm.push(storage, &key, &first, 1, Some(10)).unwrap(); // store the second item at block 5, which does not expire -svm.push(storage, &key, &second, b!(5), None).unwrap(); +svm.push(storage, &key, &second, 5, None).unwrap(); // remove the second item (ID: 1) at height 15 -svm.remove(storage, &key, 1, b!(15)).unwrap(); +svm.remove(storage, &key, 1, 15).unwrap(); // the vector at block 3 should contain only the first item assert_eq!( - svm.load_all(storage, &key, b!(3)).unwrap(), + svm.load_all(storage, &key, 3).unwrap(), vec![LoadedItem { id: 0, item: first.clone(), - expiration: Some(Expiration::AtHeight(11)), + expiration: Some(11), }] ); // the vector at block 7 should contain both items assert_eq!( - svm.load_all(storage, &key, b!(7)).unwrap(), + svm.load_all(storage, &key, 7).unwrap(), vec![ LoadedItem { id: 0, item: first.clone(), - expiration: Some(Expiration::AtHeight(11)), + expiration: Some(11), }, LoadedItem { id: 1, @@ -109,7 +97,7 @@ assert_eq!( // the vector at block 12 should contain only the first item assert_eq!( - svm.load_all(storage, &key, b!(12)).unwrap(), + svm.load_all(storage, &key, 12).unwrap(), vec![LoadedItem { id: 1, item: second.clone(), @@ -118,8 +106,5 @@ assert_eq!( ); // the vector at block 17 should contain nothing -assert_eq!( - svm.load_all(storage, &key, b!(17)).unwrap(), - vec![] -); +assert_eq!(svm.load_all(storage, &key, 17).unwrap(), vec![]); ``` diff --git a/packages/cw-snapshot-vector-map/src/lib.rs b/packages/cw-snapshot-vector-map/src/lib.rs index 4ac228df1..acce6fdad 100644 --- a/packages/cw-snapshot-vector-map/src/lib.rs +++ b/packages/cw-snapshot-vector-map/src/lib.rs @@ -1,11 +1,9 @@ #![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] -use cw20::Expiration; -use cw_utils::Duration; use serde::de::DeserializeOwned; use serde::Serialize; -use cosmwasm_std::{BlockInfo, StdResult, Storage}; +use cosmwasm_std::{StdResult, Storage}; use cw_storage_plus::{KeyDeserialize, Map, Prefixer, PrimaryKey, SnapshotMap, Strategy}; /// Map to a vector that allows reading the subset of items that existed at a @@ -17,8 +15,8 @@ pub struct SnapshotVectorMap<'a, K, V> { /// The next item ID to use per-key. next_ids: Map<'a, K, u64>, /// The IDs of the items that are active for a key at a given height, and - /// optionally when they expire. - active: SnapshotMap<'a, K, Vec<(u64, Option)>>, + /// optionally the height at which they expire. + active: SnapshotMap<'a, K, Vec<(u64, Option)>>, } /// A loaded item from the vector, including its ID and expiration. @@ -29,8 +27,8 @@ pub struct LoadedItem { pub id: u64, /// The item. pub item: V, - /// When the item expires, if set. - pub expiration: Option, + /// The block height at which the item expires, if set. + pub expiration: Option, } impl<'a, K, V> SnapshotVectorMap<'a, K, V> { @@ -78,17 +76,17 @@ where // &(key, ID) is a key in a map for<'b> &'b (K, u64): PrimaryKey<'b>, { - /// Adds an item to the vector at the current block, optionally expiring in - /// the future, returning the ID of the new item. This block should be - /// greater than or equal to the blocks all previous items were + /// Adds an item to the vector at the current block height, optionally + /// expiring in the future, returning the ID of the new item. This block + /// should be greater than or equal to the blocks all previous items were /// added/removed at. Pushing to the past will lead to incorrect behavior. pub fn push( &self, store: &mut dyn Storage, k: &K, data: &V, - block: &BlockInfo, - expire_in: Option, + curr_height: u64, + expire_in: Option, ) -> StdResult { // get next ID for the key, defaulting to 0 let next_id = self @@ -104,14 +102,14 @@ where // remove expired items active.retain(|(_, expiration)| { - expiration.map_or(true, |expiration| !expiration.is_expired(block)) + expiration.map_or(true, |expiration| expiration > curr_height) }); // add new item and save list - active.push((next_id, expire_in.map(|d| d.after(block)))); + active.push((next_id, expire_in.map(|d| curr_height + d))); // save the new list - self.active.save(store, k.clone(), &active, block.height)?; + self.active.save(store, k.clone(), &active, curr_height)?; // update next ID self.next_ids.save(store, k.clone(), &(next_id + 1))?; @@ -119,8 +117,8 @@ where Ok(next_id) } - /// Removes an item from the vector by ID and returns it. The block should - /// be greater than or equal to the blocks all previous items were + /// Removes an item from the vector by ID and returns it. The block height + /// should be greater than or equal to the blocks all previous items were /// added/removed at. Removing from the past will lead to incorrect /// behavior. pub fn remove( @@ -128,18 +126,18 @@ where store: &mut dyn Storage, k: &K, id: u64, - block: &BlockInfo, + curr_height: u64, ) -> StdResult { // get active list for the key let mut active = self.active.may_load(store, k.clone())?.unwrap_or_default(); // remove item and any expired items active.retain(|(active_id, expiration)| { - active_id != &id && expiration.map_or(true, |expiration| !expiration.is_expired(block)) + active_id != &id && expiration.map_or(true, |expiration| expiration > curr_height) }); // save the new list - self.active.save(store, k.clone(), &active, block.height)?; + self.active.save(store, k.clone(), &active, curr_height)?; // load and return the item self.load_item(store, k, id) @@ -150,7 +148,7 @@ where &self, store: &dyn Storage, k: &K, - block: &BlockInfo, + height: u64, limit: Option, offset: Option, ) -> StdResult>> { @@ -159,13 +157,13 @@ where let active_ids = self .active - .may_load_at_height(store, k.clone(), block.height)? + .may_load_at_height(store, k.clone(), height)? .unwrap_or_default(); // load paged items, skipping expired ones let items = active_ids .iter() - .filter(|(_, expiration)| expiration.map_or(true, |exp| !exp.is_expired(block))) + .filter(|(_, expiration)| expiration.map_or(true, |exp| exp > height)) .skip(offset) .take(limit) .map(|(id, expiration)| -> StdResult> { @@ -181,14 +179,14 @@ where Ok(items) } - /// Loads all items at the given block that are not expired. + /// Loads all items at the given block height that are not expired. pub fn load_all( &self, store: &dyn Storage, k: &K, - block: &BlockInfo, + height: u64, ) -> StdResult>> { - self.load(store, k, block, None, None) + self.load(store, k, height, None, None) } /// Loads an item from the vector by ID. diff --git a/packages/cw-snapshot-vector-map/src/tests.rs b/packages/cw-snapshot-vector-map/src/tests.rs index 79e1978aa..e5663dd45 100644 --- a/packages/cw-snapshot-vector-map/src/tests.rs +++ b/packages/cw-snapshot-vector-map/src/tests.rs @@ -1,19 +1,7 @@ -use cosmwasm_std::{testing::mock_dependencies, Addr, BlockInfo, Timestamp}; -use cw20::Expiration; -use cw_utils::Duration; +use cosmwasm_std::{testing::mock_dependencies, Addr}; use crate::{LoadedItem, SnapshotVectorMap}; -macro_rules! b { - ($x:expr) => { - &BlockInfo { - chain_id: "".to_string(), - height: $x, - time: Timestamp::from_seconds($x), - } - }; -} - #[test] fn test_basic() { let storage = &mut mock_dependencies().storage; @@ -28,16 +16,16 @@ fn test_basic() { let k2 = &Addr::unchecked("ekez"); // add 1, 2, 3 to k1 at corresponding blocks - svm.push(storage, k1, &1, b!(1), None).unwrap(); - svm.push(storage, k1, &2, b!(2), None).unwrap(); - svm.push(storage, k1, &3, b!(3), None).unwrap(); + svm.push(storage, k1, &1, 1, None).unwrap(); + svm.push(storage, k1, &2, 2, None).unwrap(); + svm.push(storage, k1, &3, 3, None).unwrap(); // add 1, 3 to k2 at corresponding blocks - svm.push(storage, k2, &1, b!(1), None).unwrap(); - svm.push(storage, k2, &3, b!(3), None).unwrap(); + svm.push(storage, k2, &1, 1, None).unwrap(); + svm.push(storage, k2, &3, 3, None).unwrap(); // items update one block later - let items1_b2 = svm.load_all(storage, k1, b!(2)).unwrap(); + let items1_b2 = svm.load_all(storage, k1, 2).unwrap(); assert_eq!( items1_b2, vec![LoadedItem { @@ -48,7 +36,7 @@ fn test_basic() { ); // items update one block later - let items1_b4 = svm.load_all(storage, k1, b!(4)).unwrap(); + let items1_b4 = svm.load_all(storage, k1, 4).unwrap(); assert_eq!( items1_b4, vec![ @@ -71,7 +59,7 @@ fn test_basic() { ); // items update one block later - let items2_b3 = svm.load_all(storage, k2, b!(3)).unwrap(); + let items2_b3 = svm.load_all(storage, k2, 3).unwrap(); assert_eq!( items2_b3, vec![LoadedItem { @@ -82,10 +70,10 @@ fn test_basic() { ); // remove item 2 (ID 1) from k1 at block 4 - svm.remove(storage, k1, 1, b!(4)).unwrap(); + svm.remove(storage, k1, 1, 4).unwrap(); // items update one block later - let items1_b5 = svm.load_all(storage, k1, b!(5)).unwrap(); + let items1_b5 = svm.load_all(storage, k1, 5).unwrap(); assert_eq!( items1_b5, vec![ @@ -115,45 +103,44 @@ fn test_expiration() { ); let k1 = &Addr::unchecked("haon"); - svm.push(storage, k1, &1, b!(1), Some(Duration::Height(3))) - .unwrap(); - svm.push(storage, k1, &4, b!(4), None).unwrap(); + svm.push(storage, k1, &1, 1, Some(3)).unwrap(); + svm.push(storage, k1, &4, 4, None).unwrap(); // items update one block later - let items1_b2 = svm.load_all(storage, k1, b!(2)).unwrap(); + let items1_b2 = svm.load_all(storage, k1, 2).unwrap(); assert_eq!( items1_b2, vec![LoadedItem { id: 0, item: 1, - expiration: Some(Expiration::AtHeight(4)), + expiration: Some(4), }] ); // not expired yet - let items1_b3 = svm.load_all(storage, k1, b!(3)).unwrap(); + let items1_b3 = svm.load_all(storage, k1, 3).unwrap(); assert_eq!( items1_b3, vec![LoadedItem { id: 0, item: 1, - expiration: Some(Expiration::AtHeight(4)), + expiration: Some(4), }] ); // expired: // load returns nothing - let items1_b4 = svm.load_all(storage, k1, b!(4)).unwrap(); + let items1_b4 = svm.load_all(storage, k1, 4).unwrap(); assert_eq!(items1_b4, vec![]); // but vector still has item since the list hasn't been updated let active = svm .active .may_load_at_height(storage, k1.clone(), 4) .unwrap(); - assert_eq!(active, Some(vec![(0, Some(Expiration::AtHeight(4)))])); + assert_eq!(active, Some(vec![(0, Some(4))])); // new item exists now - let items1_b5 = svm.load_all(storage, k1, b!(5)).unwrap(); + let items1_b5 = svm.load_all(storage, k1, 5).unwrap(); assert_eq!( items1_b5, vec![LoadedItem { @@ -164,10 +151,9 @@ fn test_expiration() { ); // add item that will expire - svm.push(storage, k1, &5, b!(5), Some(Duration::Height(3))) - .unwrap(); + svm.push(storage, k1, &5, 5, Some(3)).unwrap(); - let items1_b6 = svm.load_all(storage, k1, b!(6)).unwrap(); + let items1_b6 = svm.load_all(storage, k1, 6).unwrap(); assert_eq!( items1_b6, vec![ @@ -179,16 +165,16 @@ fn test_expiration() { LoadedItem { id: 2, item: 5, - expiration: Some(Expiration::AtHeight(8)), + expiration: Some(8), } ] ); // removing first item at block 8 should expire the second item as well - svm.remove(storage, k1, 1, b!(8)).unwrap(); + svm.remove(storage, k1, 1, 8).unwrap(); // load returns nothing (items update one block later) - let items1_b9 = svm.load_all(storage, k1, b!(9)).unwrap(); + let items1_b9 = svm.load_all(storage, k1, 9).unwrap(); assert_eq!(items1_b9, vec![]); // and vector is empty since the remove updated the list let active = svm @@ -198,24 +184,23 @@ fn test_expiration() { assert_eq!(active, Some(vec![])); // add item that will expire - svm.push(storage, k1, &9, b!(9), Some(Duration::Height(2))) - .unwrap(); + svm.push(storage, k1, &9, 9, Some(2)).unwrap(); - let items1_b10 = svm.load_all(storage, k1, b!(10)).unwrap(); + let items1_b10 = svm.load_all(storage, k1, 10).unwrap(); assert_eq!( items1_b10, vec![LoadedItem { id: 3, item: 9, - expiration: Some(Expiration::AtHeight(11)) + expiration: Some(11) }] ); // push item at block 11, which should expire the existing item - svm.push(storage, k1, &11, b!(11), None).unwrap(); + svm.push(storage, k1, &11, 11, None).unwrap(); // load returns just the pushed item - let items1_b12 = svm.load_all(storage, k1, b!(12)).unwrap(); + let items1_b12 = svm.load_all(storage, k1, 12).unwrap(); assert_eq!( items1_b12, vec![LoadedItem { From ed1a2a8c5770b47c2a5a37457964ee9e82354e8d Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 11 Oct 2024 17:15:03 -0400 Subject: [PATCH 04/24] continued working on delegations --- Cargo.lock | 12 +- .../delegation/dao-vote-delegation/Cargo.toml | 12 +- .../dao-vote-delegation/src/contract.rs | 383 ++++++++++++++++++ .../dao-vote-delegation/src/error.rs | 36 +- .../dao-vote-delegation/src/helpers.rs | 34 ++ .../delegation/dao-vote-delegation/src/lib.rs | 1 + .../delegation/dao-vote-delegation/src/msg.rs | 83 ++-- .../dao-vote-delegation/src/state.rs | 48 ++- 8 files changed, 542 insertions(+), 67 deletions(-) create mode 100644 contracts/delegation/dao-vote-delegation/src/contract.rs create mode 100644 contracts/delegation/dao-vote-delegation/src/helpers.rs diff --git a/Cargo.lock b/Cargo.lock index b5b8ace58..72c10e800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2383,24 +2383,14 @@ dependencies = [ "cosmwasm-std", "cw-controllers 1.1.2", "cw-multi-test", - "cw-ownable", + "cw-snapshot-vector-map", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw2 1.1.2", - "cw20 1.1.2", - "cw20-base 1.1.2", - "cw20-stake 2.6.0", - "cw4 1.1.2", - "cw4-group 1.1.2", - "cw721-base 0.18.0", "dao-hooks 2.6.0", "dao-interface 2.6.0", "dao-testing", "dao-voting 2.6.0", - "dao-voting-cw20-staked", - "dao-voting-cw4 2.6.0", - "dao-voting-cw721-staked", - "dao-voting-token-staked", "semver", "thiserror", ] diff --git a/contracts/delegation/dao-vote-delegation/Cargo.toml b/contracts/delegation/dao-vote-delegation/Cargo.toml index edb2b4e98..c082cff4c 100644 --- a/contracts/delegation/dao-vote-delegation/Cargo.toml +++ b/contracts/delegation/dao-vote-delegation/Cargo.toml @@ -19,11 +19,8 @@ library = [] cosmwasm-std = { workspace = true } cosmwasm-schema = { workspace = true } cw2 = { workspace = true } -cw4 = { workspace = true } -cw20 = { workspace = true } -cw20-base = { workspace = true, features = ["library"] } cw-controllers = { workspace = true } -cw-ownable = { workspace = true } +cw-snapshot-vector-map = { workspace = true } cw-storage-plus = { workspace = true } cw-utils = { workspace = true } dao-hooks = { workspace = true } @@ -35,11 +32,4 @@ thiserror = { workspace = true } [dev-dependencies] cw-multi-test = { workspace = true } anyhow = { workspace = true } -cw20-stake = { workspace = true, features = ["library"] } -cw4-group = { workspace = true, features = ["library"] } -cw721-base = { workspace = true, features = ["library"] } -dao-voting-cw20-staked = { workspace = true, features = ["library"] } -dao-voting-cw4 = { workspace = true, features = ["library"] } -dao-voting-token-staked = { workspace = true, features = ["library"] } -dao-voting-cw721-staked = { workspace = true, features = ["library"] } dao-testing = { workspace = true } diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs new file mode 100644 index 000000000..6db73ea08 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -0,0 +1,383 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::{ + entry_point, to_json_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Response, + StdError, StdResult, Uint128, +}; +use cw2::{get_contract_version, set_contract_version}; +use cw_utils::nonpayable; +use dao_interface::voting::InfoResponse; +use semver::Version; + +use crate::helpers::{calculate_delegated_vp, get_voting_power, is_delegate_registered}; +use crate::msg::{ + DelegationsResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, OptionalUpdate, QueryMsg, +}; +use crate::state::{ + Config, Delegate, Delegation, CONFIG, DAO, DELEGATED_VP, DELEGATED_VP_AMOUNTS, DELEGATES, + DELEGATIONS, DELEGATION_IDS, PERCENT_DELEGATED, UNVOTED_DELEGATED_VP, +}; +use crate::ContractError; + +pub(crate) const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub const DEFAULT_LIMIT: u32 = 10; +pub const MAX_LIMIT: u32 = 50; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let dao = msg + .dao + .map(|d| deps.api.addr_validate(&d)) + .transpose()? + .unwrap_or(info.sender); + + DAO.save(deps.storage, &dao)?; + + CONFIG.save( + deps.storage, + &Config { + vp_cap_percent: msg.vp_cap_percent, + }, + )?; + + Ok(Response::new().add_attribute("dao", dao)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Register {} => execute_register(deps, env, info), + ExecuteMsg::Unregister {} => execute_unregister(deps, env, info), + ExecuteMsg::Delegate { delegate, percent } => { + execute_delegate(deps, env, info, delegate, percent) + } + ExecuteMsg::Undelegate { delegate } => execute_undelegate(deps, env, info, delegate), + ExecuteMsg::UpdateConfig { vp_cap_percent, .. } => { + execute_update_config(deps, info, vp_cap_percent) + } + ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), + ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), + ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), + } +} + +fn execute_register(deps: DepsMut, env: Env, info: MessageInfo) -> Result { + nonpayable(&info)?; + + let delegate = info.sender; + + if is_delegate_registered(deps.as_ref(), &delegate, env.block.height)? { + return Err(ContractError::DelegateAlreadyRegistered {}); + } + + // ensure delegate has voting power in the DAO + let vp = get_voting_power(deps.as_ref(), &delegate, None)?; + if vp.is_zero() { + return Err(ContractError::NoVotingPower {}); + } + + DELEGATES.save(deps.storage, delegate, &Delegate {}, env.block.height)?; + + Ok(Response::new()) +} + +fn execute_unregister( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + nonpayable(&info)?; + + let delegate = info.sender; + + if !is_delegate_registered(deps.as_ref(), &delegate, env.block.height)? { + return Err(ContractError::DelegateNotRegistered {}); + } + + DELEGATES.remove(deps.storage, delegate, env.block.height)?; + + Ok(Response::new()) +} + +fn execute_delegate( + deps: DepsMut, + env: Env, + info: MessageInfo, + delegate: String, + percent: Decimal, +) -> Result { + nonpayable(&info)?; + + if percent <= Decimal::zero() { + return Err(ContractError::InvalidVotingPowerPercent {}); + } + + let delegator = info.sender; + + // prevent self delegation + let delegate = deps.api.addr_validate(&delegate)?; + if delegate == delegator { + return Err(ContractError::CannotDelegateToSelf {}); + } + + // ensure delegator has voting power in the DAO + let vp = get_voting_power(deps.as_ref(), &delegator, None)?; + if vp.is_zero() { + return Err(ContractError::NoVotingPower {}); + } + + // prevent duplicate delegation + let delegation_exists = DELEGATION_IDS.has(deps.storage, (&delegator, &delegate)); + if delegation_exists { + return Err(ContractError::DelegationAlreadyExists {}); + } + + // ensure delegate is registered + if !is_delegate_registered(deps.as_ref(), &delegate, env.block.height)? { + return Err(ContractError::DelegateNotRegistered {}); + } + + // ensure not delegating more than 100% + let current_percent_delegated = PERCENT_DELEGATED + .may_load(deps.storage, &delegator)? + .unwrap_or_default(); + let new_percent_delegated = current_percent_delegated.checked_add(percent)?; + if new_percent_delegated > Decimal::one() { + return Err(ContractError::CannotDelegateMoreThan100Percent { + current: current_percent_delegated + .checked_mul(Decimal::new(100u128.into()))? + .to_string(), + }); + } + + // add new delegation + let delegation_id = DELEGATIONS.push( + deps.storage, + &delegator, + &Delegation { + delegate: delegate.clone(), + percent, + }, + env.block.height, + // TODO: expiry?? + None, + )?; + + DELEGATION_IDS.save(deps.storage, (&delegator, &delegate), &delegation_id)?; + PERCENT_DELEGATED.save(deps.storage, &delegator, &new_percent_delegated)?; + + // add the delegated VP to the delegate's total delegated VP + let delegated_vp = calculate_delegated_vp(vp, percent); + DELEGATED_VP.update( + deps.storage, + &delegate, + env.block.height, + |vp| -> StdResult { + Ok(vp + .unwrap_or_default() + .checked_add(delegated_vp) + .map_err(StdError::overflow)?) + }, + )?; + DELEGATED_VP_AMOUNTS.save(deps.storage, (&delegator, &delegate), &delegated_vp)?; + + Ok(Response::new()) +} + +fn execute_undelegate( + deps: DepsMut, + env: Env, + info: MessageInfo, + delegate: String, +) -> Result { + nonpayable(&info)?; + + let delegator = info.sender; + let delegate = deps.api.addr_validate(&delegate)?; + + // ensure delegation exists + let existing_id = DELEGATION_IDS + .load(deps.storage, (&delegator, &delegate)) + .map_err(|_| ContractError::DelegationDoesNotExist {})?; + + // if delegation exists above, percent will exist + let current_percent_delegated = PERCENT_DELEGATED.load(deps.storage, &delegator)?; + + // retrieve and remove delegation + let delegation = DELEGATIONS.remove(deps.storage, &delegator, existing_id, env.block.height)?; + DELEGATION_IDS.remove(deps.storage, (&delegator, &delegate)); + + // update delegator's percent delegated + let new_percent_delegated = current_percent_delegated.checked_sub(delegation.percent)?; + PERCENT_DELEGATED.save(deps.storage, &delegator, &new_percent_delegated)?; + + // remove delegated VP from delegate's total delegated VP + let current_delegated_vp = DELEGATED_VP_AMOUNTS.load(deps.storage, (&delegator, &delegate))?; + DELEGATED_VP.update( + deps.storage, + &delegate, + env.block.height, + |vp| -> StdResult { + Ok(vp + // must exist if delegation was added in the past + .ok_or(StdError::not_found("delegate's total delegated VP"))? + .checked_sub(current_delegated_vp) + .map_err(StdError::overflow)?) + }, + )?; + DELEGATED_VP_AMOUNTS.remove(deps.storage, (&delegator, &delegate)); + + Ok(Response::new()) +} + +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + vp_cap_percent: Option>, +) -> Result { + nonpayable(&info)?; + + // only the DAO can update the config + let dao = DAO.load(deps.storage)?; + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + let mut config = CONFIG.load(deps.storage)?; + + if let Some(vp_cap_percent) = vp_cap_percent { + match vp_cap_percent { + OptionalUpdate::Set(vp_cap_percent) => config.vp_cap_percent = Some(vp_cap_percent), + OptionalUpdate::Clear => config.vp_cap_percent = None, + } + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new()) +} + +#[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::Delegations { + delegator, + height, + offset, + limit, + } => Ok(to_json_binary(&query_delegations( + deps, env, delegator, height, offset, limit, + )?)?), + QueryMsg::UnvotedDelegatedVotingPower { + delegate, + proposal_module, + proposal_id, + height, + } => Ok(to_json_binary(&query_unvoted_delegated_vp( + deps, + delegate, + proposal_module, + proposal_id, + height, + )?)?), + } +} + +fn query_info(deps: Deps) -> StdResult { + let info = get_contract_version(deps.storage)?; + Ok(InfoResponse { info }) +} + +fn query_delegations( + deps: Deps, + env: Env, + delegator: String, + height: Option, + offset: Option, + limit: Option, +) -> StdResult { + let height = height.unwrap_or(env.block.height); + let delegator = deps.api.addr_validate(&delegator)?; + let delegations = DELEGATIONS + .load(deps.storage, &delegator, height, limit, offset)? + .into_iter() + .map(|d| d.item) + .collect(); + Ok(DelegationsResponse { + delegations, + height, + }) +} + +fn query_unvoted_delegated_vp( + deps: Deps, + delegate: String, + proposal_module: String, + proposal_id: u64, + height: u64, +) -> StdResult { + let delegate = deps.api.addr_validate(&delegate)?; + + // if delegate not registered, they have no unvoted delegated VP. + if !is_delegate_registered(deps, &delegate, height)? { + return Ok(Uint128::zero()); + } + + let proposal_module = deps.api.addr_validate(&proposal_module)?; + + // if no unvoted delegated VP exists for the proposal, use the delegate's + // total delegated VP at that height. UNVOTED_DELEGATED_VP gets set when the + // delegate or one of their delegators casts a vote. if empty, none of them + // have voted yet. + let udvp = match UNVOTED_DELEGATED_VP + .may_load(deps.storage, (&delegate, &proposal_module, proposal_id))? + { + Some(vp) => vp, + None => DELEGATED_VP + .may_load_at_height(deps.storage, &delegate, height)? + .unwrap_or_default(), + }; + + Ok(udvp) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + let contract_version = get_contract_version(deps.storage)?; + + if contract_version.contract != CONTRACT_NAME { + return Err(ContractError::MigrationErrorIncorrectContract { + expected: CONTRACT_NAME.to_string(), + actual: contract_version.contract, + }); + } + + let new_version: Version = CONTRACT_VERSION.parse()?; + let current_version: Version = contract_version.version.parse()?; + + // only allow upgrades + if new_version <= current_version { + return Err(ContractError::MigrationErrorInvalidVersion { + new: new_version.to_string(), + current: current_version.to_string(), + }); + } + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + Ok(Response::default()) +} diff --git a/contracts/delegation/dao-vote-delegation/src/error.rs b/contracts/delegation/dao-vote-delegation/src/error.rs index cfcf562c6..defd24886 100644 --- a/contracts/delegation/dao-vote-delegation/src/error.rs +++ b/contracts/delegation/dao-vote-delegation/src/error.rs @@ -7,9 +7,6 @@ pub enum ContractError { #[error(transparent)] Std(#[from] StdError), - #[error(transparent)] - Ownable(#[from] cw_ownable::OwnershipError), - #[error(transparent)] Overflow(#[from] OverflowError), @@ -21,6 +18,39 @@ pub enum ContractError { #[error("semver parsing error: {0}")] SemVer(String), + + #[error("unauthorized")] + Unauthorized {}, + + #[error("delegate already registered")] + DelegateAlreadyRegistered {}, + + #[error("delegate not registered")] + DelegateNotRegistered {}, + + #[error("no voting power to delegate")] + NoVotingPower {}, + + #[error("cannot delegate to self")] + CannotDelegateToSelf {}, + + #[error("delegation already exists")] + DelegationAlreadyExists {}, + + #[error("delegation does not exist")] + DelegationDoesNotExist {}, + + #[error("cannot delegate more than 100% (current: {current}%)")] + CannotDelegateMoreThan100Percent { current: String }, + + #[error("invalid voting power percent")] + InvalidVotingPowerPercent {}, + + #[error("migration error: incorrect contract: expected {expected}, actual {actual}")] + MigrationErrorIncorrectContract { expected: String, actual: String }, + + #[error("migration error: invalid version: new {new}, current {current}")] + MigrationErrorInvalidVersion { new: String, current: String }, } impl From for ContractError { diff --git a/contracts/delegation/dao-vote-delegation/src/helpers.rs b/contracts/delegation/dao-vote-delegation/src/helpers.rs new file mode 100644 index 000000000..8da3f46a6 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/helpers.rs @@ -0,0 +1,34 @@ +use cosmwasm_std::{Addr, Decimal, Deps, StdResult, Uint128}; + +use dao_interface::voting; + +use crate::state::{DAO, DELEGATES}; + +pub fn is_delegate_registered(deps: Deps, delegate: &Addr, height: u64) -> StdResult { + DELEGATES + .may_load_at_height(deps.storage, delegate.clone(), height) + .map(|d| d.is_some()) +} + +pub fn get_voting_power(deps: Deps, addr: &Addr, height: Option) -> StdResult { + let dao = DAO.load(deps.storage)?; + + let voting_power: voting::VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( + &dao, + &voting::Query::VotingPowerAtHeight { + address: addr.to_string(), + height, + }, + )?; + + Ok(voting_power.power) +} + +// TODO: precision factor??? +pub fn calculate_delegated_vp(vp: Uint128, percent: Decimal) -> Uint128 { + if percent.is_zero() { + return Uint128::zero(); + } + + vp.mul_floor(percent) +} diff --git a/contracts/delegation/dao-vote-delegation/src/lib.rs b/contracts/delegation/dao-vote-delegation/src/lib.rs index d4a73c5be..c7e09cbe7 100644 --- a/contracts/delegation/dao-vote-delegation/src/lib.rs +++ b/contracts/delegation/dao-vote-delegation/src/lib.rs @@ -2,6 +2,7 @@ pub mod contract; mod error; +mod helpers; pub mod msg; pub mod state; diff --git a/contracts/delegation/dao-vote-delegation/src/msg.rs b/contracts/delegation/dao-vote-delegation/src/msg.rs index 0222ccec4..38caced02 100644 --- a/contracts/delegation/dao-vote-delegation/src/msg.rs +++ b/contracts/delegation/dao-vote-delegation/src/msg.rs @@ -1,13 +1,9 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{Addr, Decimal}; +use cosmwasm_std::{Decimal, Uint128}; use cw4::MemberChangedHookMsg; -use cw_ownable::cw_ownable_execute; -use cw_utils::Duration; use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; use dao_interface::voting::InfoResponse; -pub use cw_ownable::Ownership; - use crate::state::Delegation; #[cw_serde] @@ -18,14 +14,39 @@ pub struct InstantiateMsg { /// they can be delegated any amount of voting power—this cap is only /// applied when casting votes. pub vp_cap_percent: Option, - /// the duration a delegation is valid for, after which it must be renewed - /// by the delegator. - pub delegation_validity: Option, + // /// the duration a delegation is valid for, after which it must be renewed + // /// by the delegator. + // pub delegation_validity: Option, } -#[cw_ownable_execute] #[cw_serde] pub enum ExecuteMsg { + /// Register as a delegate. + Register {}, + /// Unregister as a delegate. + Unregister {}, + /// Create a delegation. + Delegate { + /// the delegate to delegate to + delegate: String, + /// the percent of voting power to delegate + percent: Decimal, + }, + /// Revoke a delegation. + Undelegate { + /// the delegate to undelegate from + delegate: String, + }, + /// Updates the configuration of the delegation system. + UpdateConfig { + /// the maximum percent of voting power that a single delegate can + /// wield. they can be delegated any amount of voting power—this cap is + /// only applied when casting votes. + vp_cap_percent: Option>, + // /// the duration a delegation is valid for, after which it must be + // /// renewed by the delegator. + // delegation_validity: Option, + }, /// Called when a member is added or removed /// to a cw4-groups or cw721-roles contract. MemberChangedHook(MemberChangedHookMsg), @@ -33,16 +54,12 @@ pub enum ExecuteMsg { NftStakeChangeHook(NftStakeChangedHookMsg), /// Called when tokens are staked or unstaked. StakeChangeHook(StakeChangedHookMsg), - /// updates the configuration of the delegation system - UpdateConfig { - /// the maximum percent of voting power that a single delegate can - /// wield. they can be delegated any amount of voting power—this cap is - /// only applied when casting votes. - vp_cap_percent: Option, - /// the duration a delegation is valid for, after which it must be - /// renewed by the delegator. - delegation_validity: Option, - }, +} + +#[cw_serde] +pub enum OptionalUpdate { + Set(T), + Clear, } #[cw_serde] @@ -51,28 +68,32 @@ pub enum QueryMsg { /// Returns contract version info #[returns(InfoResponse)] Info {}, - /// Returns information about the ownership of this contract. - #[returns(Ownership)] - Ownership {}, - /// Returns the delegations by a delegator. + /// Returns the delegations by a delegator, optionally at a given height. + /// Uses the current block height if not provided. #[returns(DelegationsResponse)] - DelegatorDelegations { + Delegations { delegator: String, - start_after: Option, - limit: Option, + height: Option, + offset: Option, + limit: Option, }, - /// Returns the delegations to a delegate. - #[returns(DelegationsResponse)] - DelegateDelegations { + /// Returns the VP delegated to a delegate that has not yet been used in + /// votes cast by delegators in a specific proposal. + #[returns(Uint128)] + UnvotedDelegatedVotingPower { delegate: String, - start_after: Option, - limit: Option, + proposal_module: String, + proposal_id: u64, + height: u64, }, } #[cw_serde] pub struct DelegationsResponse { + /// The delegations. pub delegations: Vec, + /// The height at which the delegations were loaded. + pub height: u64, } #[cw_serde] diff --git a/contracts/delegation/dao-vote-delegation/src/state.rs b/contracts/delegation/dao-vote-delegation/src/state.rs index 977efaa75..dea8f52bf 100644 --- a/contracts/delegation/dao-vote-delegation/src/state.rs +++ b/contracts/delegation/dao-vote-delegation/src/state.rs @@ -1,8 +1,7 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{Addr, Decimal, Uint128}; -use cw20::Expiration; +use cw_snapshot_vector_map::SnapshotVectorMap; use cw_storage_plus::{Item, Map, SnapshotMap, Strategy}; -use cw_utils::Duration; /// the configuration of the delegation system. pub const CONFIG: Item = Item::new("config"); @@ -10,9 +9,17 @@ pub const CONFIG: Item = Item::new("config"); /// the DAO this delegation system is connected to. pub const DAO: Item = Item::new("dao"); +/// the delegates. +pub const DELEGATES: SnapshotMap = SnapshotMap::new( + "delegates", + "delegates__checkpoints", + "delegates__changelog", + Strategy::EveryBlock, +); + /// the VP delegated to a delegate that has not yet been used in votes cast by -/// delegators in a specific proposal. -pub const UNVOTED_DELEGATED_VP: Map<(&Addr, u64), Uint128> = Map::new("udvp"); +/// delegators in a specific proposal (module, ID). +pub const UNVOTED_DELEGATED_VP: Map<(&Addr, &Addr, u64), Uint128> = Map::new("udvp"); /// the VP delegated to a delegate by height. pub const DELEGATED_VP: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( @@ -22,26 +29,45 @@ pub const DELEGATED_VP: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( Strategy::EveryBlock, ); +/// the delegations of a delegator. +pub const DELEGATIONS: SnapshotVectorMap = SnapshotVectorMap::new( + "d__items", + "d__next_ids", + "d__active", + "d__active__checkpoints", + "d__active__changelog", +); + +/// map (delegator, delegate) -> ID of the delegation in the vector map. this is +/// useful for quickly checking if a delegation already exists, and for +/// undelegating. +pub const DELEGATION_IDS: Map<(&Addr, &Addr), u64> = Map::new("dids"); + +/// map (delegator, delegate) -> calculated absolute delegated VP. +pub const DELEGATED_VP_AMOUNTS: Map<(&Addr, &Addr), Uint128> = Map::new("dvp_amounts"); + +/// map delegator -> percent delegated to all delegates. +pub const PERCENT_DELEGATED: Map<&Addr, Decimal> = Map::new("pd"); + #[cw_serde] pub struct Config { /// the maximum percent of voting power that a single delegate can wield. /// they can be delegated any amount of voting power—this cap is only /// applied when casting votes. pub vp_cap_percent: Option, - /// the duration a delegation is valid for, after which it must be renewed - /// by the delegator. - pub delegation_validity: Option, + // /// the duration a delegation is valid for, after which it must be renewed + // /// by the delegator. + // pub delegation_validity: Option, } +#[cw_serde] +pub struct Delegate {} + #[cw_serde] pub struct Delegation { - /// the delegator. - pub delegator: Addr, /// the delegate that can vote on behalf of the delegator. pub delegate: Addr, /// the percent of the delegator's voting power that is delegated to the /// delegate. pub percent: Decimal, - /// when the delegation expires. - pub expiration: Expiration, } From f601356e394209516c3bcd29bdabaa62bba7c27e Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 11 Oct 2024 22:10:58 -0400 Subject: [PATCH 05/24] more --- Cargo.lock | 10 ++ .../delegation/dao-vote-delegation/Cargo.toml | 10 ++ .../delegation/dao-vote-delegation/README.md | 4 +- .../dao-vote-delegation/src/contract.rs | 156 +++++++++++++++--- .../dao-vote-delegation/src/error.rs | 6 + .../dao-vote-delegation/src/helpers.rs | 43 ++++- .../delegation/dao-vote-delegation/src/msg.rs | 25 ++- .../dao-vote-delegation/src/state.rs | 5 +- .../dao-proposal-multiple/src/contract.rs | 9 + .../dao-proposal-single/src/contract.rs | 9 + packages/cw-snapshot-vector-map/src/lib.rs | 8 +- packages/dao-hooks/src/vote.rs | 18 +- 12 files changed, 261 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72c10e800..673d571f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2387,10 +2387,20 @@ dependencies = [ "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw2 1.1.2", + "cw20 1.1.2", + "cw20-base 1.1.2", + "cw20-stake 2.6.0", + "cw4 1.1.2", + "cw4-group 1.1.2", + "cw721-base 0.18.0", "dao-hooks 2.6.0", "dao-interface 2.6.0", "dao-testing", "dao-voting 2.6.0", + "dao-voting-cw20-staked", + "dao-voting-cw4 2.6.0", + "dao-voting-cw721-staked", + "dao-voting-token-staked", "semver", "thiserror", ] diff --git a/contracts/delegation/dao-vote-delegation/Cargo.toml b/contracts/delegation/dao-vote-delegation/Cargo.toml index c082cff4c..cb1f13953 100644 --- a/contracts/delegation/dao-vote-delegation/Cargo.toml +++ b/contracts/delegation/dao-vote-delegation/Cargo.toml @@ -19,6 +19,7 @@ library = [] cosmwasm-std = { workspace = true } cosmwasm-schema = { workspace = true } cw2 = { workspace = true } +cw4 = { workspace = true } cw-controllers = { workspace = true } cw-snapshot-vector-map = { workspace = true } cw-storage-plus = { workspace = true } @@ -32,4 +33,13 @@ thiserror = { workspace = true } [dev-dependencies] cw-multi-test = { workspace = true } anyhow = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw20-stake = { workspace = true, features = ["library"] } +cw4-group = { workspace = true, features = ["library"] } +cw721-base = { workspace = true, features = ["library"] } +dao-voting-cw20-staked = { workspace = true, features = ["library"] } +dao-voting-cw4 = { workspace = true, features = ["library"] } +dao-voting-token-staked = { workspace = true, features = ["library"] } +dao-voting-cw721-staked = { workspace = true, features = ["library"] } dao-testing = { workspace = true } diff --git a/contracts/delegation/dao-vote-delegation/README.md b/contracts/delegation/dao-vote-delegation/README.md index f73912c64..6b4e0d6f2 100644 --- a/contracts/delegation/dao-vote-delegation/README.md +++ b/contracts/delegation/dao-vote-delegation/README.md @@ -11,8 +11,8 @@ distributor, to offer a comprehensive delegation system for DAOs that supports the following features: - Fractional delegation of voting power on a per-proposal-module basis. -- Overridable delegate votes that can be overridden on a per-proposal basis by - the delegator +- Delegate votes that can be overridden on a per-proposal basis by each + delegator. - Delegate reward commission. ## Instantiation and Setup diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 6db73ea08..567eeba52 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -1,16 +1,21 @@ +use cosmwasm_std::Order; #[cfg(not(feature = "library"))] use cosmwasm_std::{ entry_point, to_json_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult, Uint128, }; use cw2::{get_contract_version, set_contract_version}; -use cw_utils::nonpayable; +use cw_snapshot_vector_map::LoadedItem; +use cw_storage_plus::Bound; +use cw_utils::{maybe_addr, nonpayable}; +use dao_hooks::vote::VoteHookMsg; use dao_interface::voting::InfoResponse; use semver::Version; -use crate::helpers::{calculate_delegated_vp, get_voting_power, is_delegate_registered}; +use crate::helpers::{calculate_delegated_vp, get_udvp, get_voting_power, is_delegate_registered}; use crate::msg::{ - DelegationsResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, OptionalUpdate, QueryMsg, + DelegateResponse, DelegatesResponse, DelegationsResponse, ExecuteMsg, InstantiateMsg, + MigrateMsg, OptionalUpdate, QueryMsg, }; use crate::state::{ Config, Delegate, Delegation, CONFIG, DAO, DELEGATED_VP, DELEGATED_VP_AMOUNTS, DELEGATES, @@ -68,9 +73,10 @@ pub fn execute( ExecuteMsg::UpdateConfig { vp_cap_percent, .. } => { execute_update_config(deps, info, vp_cap_percent) } - ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), - ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), - ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), + // ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), + // ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), + // ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), + ExecuteMsg::VoteHook(vote_hook) => execute_vote_hook(deps, env, info, vote_hook), } } @@ -79,16 +85,29 @@ fn execute_register(deps: DepsMut, env: Env, info: MessageInfo) -> Result Result { + let proposal_module = info.sender; + + // TODO: validate proposal module + + match vote_hook { + VoteHookMsg::NewVote { + proposal_id, + voter, + power, + height, + is_first_vote, + .. + } => { + // if first vote, update the unvoted delegated VP for their + // delegates by subtracting. if not first vote, this has already + // been done. + if is_first_vote { + let delegator = deps.api.addr_validate(&voter)?; + let delegates = DELEGATIONS.load_all(deps.storage, &delegator, env.block.height)?; + for LoadedItem { + item: Delegation { delegate, percent }, + .. + } in delegates + { + let udvp = get_udvp( + deps.as_ref(), + &delegate, + &proposal_module, + proposal_id, + height, + )?; + + let delegated_vp = calculate_delegated_vp(power, percent); + + // remove the delegator's delegated VP from the delegate's + // unvoted delegated VP for this proposal since this + // delegator just voted. + let new_udvp = udvp.checked_sub(delegated_vp)?; + + UNVOTED_DELEGATED_VP.save( + deps.storage, + (&delegate, &proposal_module, proposal_id), + &new_udvp, + )?; + } + } + } + } + + Ok(Response::new().add_attribute("action", "vote_hook")) +} + #[cfg_attr(not(feature = "library"), entry_point)] pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { QueryMsg::Info {} => Ok(to_json_binary(&query_info(deps)?)?), + QueryMsg::Delegates { start_after, limit } => { + Ok(to_json_binary(&query_delegates(deps, start_after, limit)?)?) + } QueryMsg::Delegations { delegator, height, @@ -302,6 +394,31 @@ fn query_info(deps: Deps) -> StdResult { Ok(InfoResponse { info }) } +fn query_delegates( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + + let start = maybe_addr(deps.api, start_after)?.map(Bound::exclusive); + + let delegates = DELEGATES + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|delegate| { + delegate.map(|(delegate, _)| -> StdResult { + let power = DELEGATED_VP + .may_load(deps.storage, &delegate)? + .unwrap_or_default(); + Ok(DelegateResponse { delegate, power }) + })? + }) + .collect::>()?; + + Ok(DelegatesResponse { delegates }) +} + fn query_delegations( deps: Deps, env: Env, @@ -333,26 +450,13 @@ fn query_unvoted_delegated_vp( let delegate = deps.api.addr_validate(&delegate)?; // if delegate not registered, they have no unvoted delegated VP. - if !is_delegate_registered(deps, &delegate, height)? { + if !is_delegate_registered(deps, &delegate, Some(height))? { return Ok(Uint128::zero()); } let proposal_module = deps.api.addr_validate(&proposal_module)?; - // if no unvoted delegated VP exists for the proposal, use the delegate's - // total delegated VP at that height. UNVOTED_DELEGATED_VP gets set when the - // delegate or one of their delegators casts a vote. if empty, none of them - // have voted yet. - let udvp = match UNVOTED_DELEGATED_VP - .may_load(deps.storage, (&delegate, &proposal_module, proposal_id))? - { - Some(vp) => vp, - None => DELEGATED_VP - .may_load_at_height(deps.storage, &delegate, height)? - .unwrap_or_default(), - }; - - Ok(udvp) + get_udvp(deps, &delegate, &proposal_module, proposal_id, height) } #[cfg_attr(not(feature = "library"), entry_point)] diff --git a/contracts/delegation/dao-vote-delegation/src/error.rs b/contracts/delegation/dao-vote-delegation/src/error.rs index defd24886..1c993b435 100644 --- a/contracts/delegation/dao-vote-delegation/src/error.rs +++ b/contracts/delegation/dao-vote-delegation/src/error.rs @@ -28,6 +28,12 @@ pub enum ContractError { #[error("delegate not registered")] DelegateNotRegistered {}, + #[error("delegates cannot delegate to others")] + DelegatesCannotDelegate {}, + + #[error("undelegate before registering as a delegate")] + UndelegateBeforeRegistering {}, + #[error("no voting power to delegate")] NoVotingPower {}, diff --git a/contracts/delegation/dao-vote-delegation/src/helpers.rs b/contracts/delegation/dao-vote-delegation/src/helpers.rs index 8da3f46a6..b2c491745 100644 --- a/contracts/delegation/dao-vote-delegation/src/helpers.rs +++ b/contracts/delegation/dao-vote-delegation/src/helpers.rs @@ -2,22 +2,26 @@ use cosmwasm_std::{Addr, Decimal, Deps, StdResult, Uint128}; use dao_interface::voting; -use crate::state::{DAO, DELEGATES}; +use crate::state::{DAO, DELEGATED_VP, DELEGATES, UNVOTED_DELEGATED_VP}; -pub fn is_delegate_registered(deps: Deps, delegate: &Addr, height: u64) -> StdResult { - DELEGATES - .may_load_at_height(deps.storage, delegate.clone(), height) - .map(|d| d.is_some()) +pub fn is_delegate_registered(deps: Deps, delegate: &Addr, height: Option) -> StdResult { + let option = if let Some(height) = height { + DELEGATES.may_load_at_height(deps.storage, delegate.clone(), height) + } else { + DELEGATES.may_load(deps.storage, delegate.clone()) + }; + + option.map(|d| d.is_some()) } -pub fn get_voting_power(deps: Deps, addr: &Addr, height: Option) -> StdResult { +pub fn get_voting_power(deps: Deps, addr: &Addr, height: u64) -> StdResult { let dao = DAO.load(deps.storage)?; let voting_power: voting::VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( &dao, &voting::Query::VotingPowerAtHeight { address: addr.to_string(), - height, + height: Some(height), }, )?; @@ -32,3 +36,28 @@ pub fn calculate_delegated_vp(vp: Uint128, percent: Decimal) -> Uint128 { vp.mul_floor(percent) } + +/// Returns the unvoted delegated VP for a delegate on a proposal, falling back +/// to the delegate's total delegated VP at the given height if no unvoted +/// delegated VP exists for the proposal. +/// +/// **NOTE: The caller is responsible for ensuring that the block height +/// corresponds to the correct height for the proposal.** +pub fn get_udvp( + deps: Deps, + delegate: &Addr, + proposal_module: &Addr, + proposal_id: u64, + height: u64, +) -> StdResult { + // if no unvoted delegated VP exists for the proposal, use the delegate's + // total delegated VP at that height. UNVOTED_DELEGATED_VP gets set when the + // delegate or one of their delegators casts a vote. if empty, none of them + // have voted yet. + match UNVOTED_DELEGATED_VP.may_load(deps.storage, (&delegate, &proposal_module, proposal_id))? { + Some(vp) => Ok(vp), + None => Ok(DELEGATED_VP + .may_load_at_height(deps.storage, &delegate, height)? + .unwrap_or_default()), + } +} diff --git a/contracts/delegation/dao-vote-delegation/src/msg.rs b/contracts/delegation/dao-vote-delegation/src/msg.rs index 38caced02..1a4c208fc 100644 --- a/contracts/delegation/dao-vote-delegation/src/msg.rs +++ b/contracts/delegation/dao-vote-delegation/src/msg.rs @@ -1,7 +1,7 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{Decimal, Uint128}; +use cosmwasm_std::{Addr, Decimal, Uint128}; use cw4::MemberChangedHookMsg; -use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg, vote::VoteHookMsg}; use dao_interface::voting::InfoResponse; use crate::state::Delegation; @@ -54,6 +54,8 @@ pub enum ExecuteMsg { NftStakeChangeHook(NftStakeChangedHookMsg), /// Called when tokens are staked or unstaked. StakeChangeHook(StakeChangedHookMsg), + /// Called when a vote is cast. + VoteHook(VoteHookMsg), } #[cw_serde] @@ -68,6 +70,11 @@ pub enum QueryMsg { /// Returns contract version info #[returns(InfoResponse)] Info {}, + #[returns(DelegatesResponse)] + Delegates { + start_after: Option, + limit: Option, + }, /// Returns the delegations by a delegator, optionally at a given height. /// Uses the current block height if not provided. #[returns(DelegationsResponse)] @@ -88,6 +95,20 @@ pub enum QueryMsg { }, } +#[cw_serde] +pub struct DelegatesResponse { + /// The delegates. + pub delegates: Vec, +} + +#[cw_serde] +pub struct DelegateResponse { + /// The delegate. + pub delegate: Addr, + /// The total voting power delegated to the delegate. + pub power: Uint128, +} + #[cw_serde] pub struct DelegationsResponse { /// The delegations. diff --git a/contracts/delegation/dao-vote-delegation/src/state.rs b/contracts/delegation/dao-vote-delegation/src/state.rs index dea8f52bf..18cfde514 100644 --- a/contracts/delegation/dao-vote-delegation/src/state.rs +++ b/contracts/delegation/dao-vote-delegation/src/state.rs @@ -17,8 +17,9 @@ pub const DELEGATES: SnapshotMap = SnapshotMap::new( Strategy::EveryBlock, ); -/// the VP delegated to a delegate that has not yet been used in votes cast by -/// delegators in a specific proposal (module, ID). +/// map (delegate, proposal_module, proposal_id) -> the VP delegated to the +/// delegate that has not yet been used in votes cast by delegators in a +/// specific proposal. pub const UNVOTED_DELEGATED_VP: Map<(&Addr, &Addr, u64), Uint128> = Map::new("udvp"); /// the VP delegated to a delegate by height. diff --git a/contracts/proposal/dao-proposal-multiple/src/contract.rs b/contracts/proposal/dao-proposal-multiple/src/contract.rs index 6fc42f899..9154fe670 100644 --- a/contracts/proposal/dao-proposal-multiple/src/contract.rs +++ b/contracts/proposal/dao-proposal-multiple/src/contract.rs @@ -402,8 +402,14 @@ pub fn execute_vote( return Err(ContractError::NotRegistered {}); } + let mut is_first_vote = true; + BALLOTS.update(deps.storage, (proposal_id, &sender), |bal| match bal { Some(current_ballot) => { + // If a ballot exists, this is not the first time the voter has + // voted on this proposal. + is_first_vote = false; + if prop.allow_revoting { if current_ballot.vote == vote { // Don't allow casting the same vote more than @@ -450,6 +456,9 @@ pub fn execute_vote( proposal_id, sender.to_string(), vote.to_string(), + vote_power, + prop.start_height, + is_first_vote, )?; Ok(Response::default() .add_submessages(change_hooks) diff --git a/contracts/proposal/dao-proposal-single/src/contract.rs b/contracts/proposal/dao-proposal-single/src/contract.rs index 1943081d1..ebb8e6414 100644 --- a/contracts/proposal/dao-proposal-single/src/contract.rs +++ b/contracts/proposal/dao-proposal-single/src/contract.rs @@ -503,8 +503,14 @@ pub fn execute_vote( return Err(ContractError::NotRegistered {}); } + let mut is_first_vote = true; + BALLOTS.update(deps.storage, (proposal_id, &sender), |bal| match bal { Some(current_ballot) => { + // If a ballot exists, this is not the first time the voter has + // voted on this proposal. + is_first_vote = false; + if prop.allow_revoting { if current_ballot.vote == vote { // Don't allow casting the same vote more than @@ -557,6 +563,9 @@ pub fn execute_vote( proposal_id, sender.to_string(), vote.to_string(), + vote_power, + prop.start_height, + is_first_vote, )?; Ok(Response::default() diff --git a/packages/cw-snapshot-vector-map/src/lib.rs b/packages/cw-snapshot-vector-map/src/lib.rs index acce6fdad..4b106831f 100644 --- a/packages/cw-snapshot-vector-map/src/lib.rs +++ b/packages/cw-snapshot-vector-map/src/lib.rs @@ -143,7 +143,9 @@ where self.load_item(store, k, id) } - /// Loads paged items at the given block that are not expired. + /// Loads paged items at the given block height that are not expired. This + /// takes 1 block to reflect updates made earlier in the same block, due to + /// how [`SnapshotMap`] is implemented. pub fn load( &self, store: &dyn Storage, @@ -179,7 +181,9 @@ where Ok(items) } - /// Loads all items at the given block height that are not expired. + /// Loads all items at the given block height that are not expired. This + /// takes 1 block to reflect updates made earlier in the same block, due to + /// how [`SnapshotMap`] is implemented. pub fn load_all( &self, store: &dyn Storage, diff --git a/packages/dao-hooks/src/vote.rs b/packages/dao-hooks/src/vote.rs index 9d5dedf3e..97bdc1f05 100644 --- a/packages/dao-hooks/src/vote.rs +++ b/packages/dao-hooks/src/vote.rs @@ -1,5 +1,5 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{to_json_binary, StdResult, Storage, SubMsg, WasmMsg}; +use cosmwasm_std::{to_json_binary, StdResult, Storage, SubMsg, Uint128, WasmMsg}; use cw_hooks::Hooks; use dao_voting::reply::mask_vote_hook_index; @@ -7,9 +7,19 @@ use dao_voting::reply::mask_vote_hook_index; #[cw_serde] pub enum VoteHookMsg { NewVote { + /// The proposal ID that was voted on. proposal_id: u64, + /// The voter that cast the vote. voter: String, + /// The vote that was cast. vote: String, + /// The voting power of the voter. + power: Uint128, + /// The block height at which the voting power is calculated. + height: u64, + /// Whether this is the first vote cast by this voter on this proposal. + /// This will always be true if revoting is disabled. + is_first_vote: bool, }, } @@ -22,11 +32,17 @@ pub fn new_vote_hooks( proposal_id: u64, voter: String, vote: String, + power: Uint128, + height: u64, + is_first_vote: bool, ) -> StdResult> { let msg = to_json_binary(&VoteHookExecuteMsg::VoteHook(VoteHookMsg::NewVote { proposal_id, voter, vote, + power, + height, + is_first_vote, }))?; let mut index: u64 = 0; hooks.prepare_hooks(storage, |a| { From 24730c67400100bb4641fe85192bf88d90b4c1b4 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sat, 12 Oct 2024 17:17:44 -0400 Subject: [PATCH 06/24] moooore --- Cargo.lock | 1 + .../delegation/dao-vote-delegation/Cargo.toml | 1 + .../delegation/dao-vote-delegation/README.md | 52 ++++ .../dao-vote-delegation/src/contract.rs | 232 ++++++++++++------ .../dao-vote-delegation/src/error.rs | 6 + .../dao-vote-delegation/src/helpers.rs | 28 ++- .../dao-vote-delegation/src/hooks.rs | 221 +++++++++++++++++ .../delegation/dao-vote-delegation/src/lib.rs | 1 + .../delegation/dao-vote-delegation/src/msg.rs | 36 +++ .../dao-vote-delegation/src/state.rs | 11 +- packages/cw-snapshot-vector-map/src/lib.rs | 55 ++++- 11 files changed, 560 insertions(+), 84 deletions(-) create mode 100644 contracts/delegation/dao-vote-delegation/src/hooks.rs diff --git a/Cargo.lock b/Cargo.lock index 673d571f8..4726378b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2383,6 +2383,7 @@ dependencies = [ "cosmwasm-std", "cw-controllers 1.1.2", "cw-multi-test", + "cw-paginate-storage 2.5.0", "cw-snapshot-vector-map", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", diff --git a/contracts/delegation/dao-vote-delegation/Cargo.toml b/contracts/delegation/dao-vote-delegation/Cargo.toml index cb1f13953..0dc06bd31 100644 --- a/contracts/delegation/dao-vote-delegation/Cargo.toml +++ b/contracts/delegation/dao-vote-delegation/Cargo.toml @@ -21,6 +21,7 @@ cosmwasm-schema = { workspace = true } cw2 = { workspace = true } cw4 = { workspace = true } cw-controllers = { workspace = true } +cw-paginate-storage = { workspace = true } cw-snapshot-vector-map = { workspace = true } cw-storage-plus = { workspace = true } cw-utils = { workspace = true } diff --git a/contracts/delegation/dao-vote-delegation/README.md b/contracts/delegation/dao-vote-delegation/README.md index 6b4e0d6f2..92d2fb826 100644 --- a/contracts/delegation/dao-vote-delegation/README.md +++ b/contracts/delegation/dao-vote-delegation/README.md @@ -52,3 +52,55 @@ delegated all of their voting power to a few different delegates then unstaked half of their tokens, there is no clear way to resolve what their new delegations are. Using percentages instead allows voting power and delegation to be decided independently. + +## Implementation Notes + +The trickiest piece of this implementation is navigating the snapshot maps, +which are the data structures used to store historical state. + +Essentially, snapshot maps (and the other historical data structures based on +snapshot maps) take 1 block to reflect updates made, but only when querying +state at a specific height (typically in the past). When using the query +functions that do not accept a height, they read the updates immediately, +including those from the same block. For example, `snapshot_map.may_load` +returns the latest map values, including those changed in the same block by an +earlier transaction; on the other hand, `snapshot_map.may_load_at_height` +returns the map values as they were at the end of the previous block (due to an +implementation detail of snapshot maps that I'm not sure was intentional). + +Ideally, we would just fix this discrepancy and move on. However, many other +modules have been built using SnapshotMaps, and it is important that all modules +behave consistently with respect to this issue. For example, voting power +queries in voting modules operate in this way, with updates delayed 1 +block—because of this, it is crucial that we compute and store delegated voting +power in the same way. Otherwise we risk introducing off-by-one inconsistencies +in voting power calculations. Thus, for now, we will accept this behavior and +continue. + +What this means for the implementation is that we must be very careful whenever +we do pretty much anything. When performing updates at the latest block, such as +when delegating or undelegating voting power, or when handling a change in +someone's voting power (in order to propagate that change to their delegates), +we need to be sure to interact with the latest delegation and voting power +state. However, when querying information from the past, we need to match the +delayed update behavior of voting power queries. + +More concretely: +- when registering/unregistering a delegate, delegating/undelegating, or + handling voting power change hooks, we need to access the account's latest + voting power (by querying `latest_height + 1`), even if it was updated in the + same block. this ensures that changes to voting power right before a + registration/delegation occurs, or voting power changes right after a + delegation occurs, are taken into account. e.g. an account should not be able + to get rid of all their voting power (i.e. stop being a member) and then + become a delegate within the same block. +- when delegating/undelegating or handling voting power change hooks, in order + to update a delegate's total delegated VP, we need to query the latest + delegated VP, even if it was updated earlier in the same block, and then + effectively "re-prepare" the total that will be reflected in historical + queries starting from the next block. `snapshot_map.update` takes care of this + automatically by loading the latest value from the same block. +- when querying information from the past, such as when querying a delegate's + total unvoted delegated VP when they cast a vote, or when a vote cast hook is + triggered for a delegator, we need to use historical queries that match the + behavior of the voting module's voting power queries, i.e. delayed by 1 block. diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 567eeba52..2128edca0 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -1,25 +1,32 @@ -use cosmwasm_std::Order; #[cfg(not(feature = "library"))] use cosmwasm_std::{ entry_point, to_json_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult, Uint128, }; +use cosmwasm_std::{Addr, Order}; use cw2::{get_contract_version, set_contract_version}; -use cw_snapshot_vector_map::LoadedItem; +use cw_paginate_storage::paginate_map_keys; use cw_storage_plus::Bound; use cw_utils::{maybe_addr, nonpayable}; -use dao_hooks::vote::VoteHookMsg; +use dao_interface::state::{ProposalModule, ProposalModuleStatus}; use dao_interface::voting::InfoResponse; use semver::Version; -use crate::helpers::{calculate_delegated_vp, get_udvp, get_voting_power, is_delegate_registered}; +use crate::helpers::{ + calculate_delegated_vp, ensure_setup, get_udvp, get_voting_power, is_delegate_registered, + unregister_delegate, +}; +use crate::hooks::{ + execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, execute_vote_hook, +}; use crate::msg::{ DelegateResponse, DelegatesResponse, DelegationsResponse, ExecuteMsg, InstantiateMsg, MigrateMsg, OptionalUpdate, QueryMsg, }; use crate::state::{ Config, Delegate, Delegation, CONFIG, DAO, DELEGATED_VP, DELEGATED_VP_AMOUNTS, DELEGATES, - DELEGATIONS, DELEGATION_IDS, PERCENT_DELEGATED, UNVOTED_DELEGATED_VP, + DELEGATIONS, DELEGATION_IDS, PERCENT_DELEGATED, PROPOSAL_HOOK_CALLERS, + VOTING_POWER_HOOK_CALLERS, }; use crate::ContractError; @@ -53,6 +60,13 @@ pub fn instantiate( }, )?; + // sync proposal modules with no limit if not disabled. this should succeed + // for most DAOs as the query will not run out of gas with only a few + // proposal modules. + if !msg.no_sync_proposal_modules.unwrap_or(false) { + execute_sync_proposal_modules(deps, None, None)?; + } + Ok(Response::new().add_attribute("dao", dao)) } @@ -70,18 +84,25 @@ pub fn execute( execute_delegate(deps, env, info, delegate, percent) } ExecuteMsg::Undelegate { delegate } => execute_undelegate(deps, env, info, delegate), + ExecuteMsg::UpdateVotingPowerHookCallers { add, remove } => { + execute_update_voting_power_hook_callers(deps, info, add, remove) + } + ExecuteMsg::SyncProposalModules { start_after, limit } => { + execute_sync_proposal_modules(deps, start_after, limit) + } ExecuteMsg::UpdateConfig { vp_cap_percent, .. } => { execute_update_config(deps, info, vp_cap_percent) } - // ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), - // ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), - // ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), + ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), + ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), + ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), ExecuteMsg::VoteHook(vote_hook) => execute_vote_hook(deps, env, info, vote_hook), } } fn execute_register(deps: DepsMut, env: Env, info: MessageInfo) -> Result { nonpayable(&info)?; + ensure_setup(deps.as_ref())?; let delegate = info.sender; @@ -119,6 +140,7 @@ fn execute_unregister( info: MessageInfo, ) -> Result { nonpayable(&info)?; + ensure_setup(deps.as_ref())?; let delegate = info.sender; @@ -126,7 +148,7 @@ fn execute_unregister( return Err(ContractError::DelegateNotRegistered {}); } - DELEGATES.remove(deps.storage, delegate, env.block.height)?; + unregister_delegate(deps, &delegate, env.block.height)?; Ok(Response::new()) } @@ -139,6 +161,7 @@ fn execute_delegate( percent: Decimal, ) -> Result { nonpayable(&info)?; + ensure_setup(deps.as_ref())?; if percent <= Decimal::zero() { return Err(ContractError::InvalidVotingPowerPercent {}); @@ -157,6 +180,17 @@ fn execute_delegate( return Err(ContractError::CannotDelegateToSelf {}); } + // ensure delegate is registered + if !is_delegate_registered(deps.as_ref(), &delegate, None)? { + return Err(ContractError::DelegateNotRegistered {}); + } + + // prevent duplicate delegation + let delegation_exists = DELEGATION_IDS.has(deps.storage, (&delegator, &delegate)); + if delegation_exists { + return Err(ContractError::DelegationAlreadyExists {}); + } + // ensure delegator has voting power in the DAO let vp = get_voting_power( deps.as_ref(), @@ -170,17 +204,6 @@ fn execute_delegate( return Err(ContractError::NoVotingPower {}); } - // prevent duplicate delegation - let delegation_exists = DELEGATION_IDS.has(deps.storage, (&delegator, &delegate)); - if delegation_exists { - return Err(ContractError::DelegationAlreadyExists {}); - } - - // ensure delegate is registered - if !is_delegate_registered(deps.as_ref(), &delegate, None)? { - return Err(ContractError::DelegateNotRegistered {}); - } - // ensure not delegating more than 100% let current_percent_delegated = PERCENT_DELEGATED .may_load(deps.storage, &delegator)? @@ -212,6 +235,13 @@ fn execute_delegate( // add the delegated VP to the delegate's total delegated VP let delegated_vp = calculate_delegated_vp(vp, percent); + // this `update` function loads the latest delegated VP, even if it was + // updated before in this block, and then saves the new total at the current + // block, which will be reflected in historical queries starting from the + // NEXT block. if future delegations/undelegations/voting power changes + // occur in this block, they will immediately load the latest state, and + // update the total that will be reflected in historical queries starting + // from the next block. DELEGATED_VP.update( deps.storage, &delegate, @@ -235,6 +265,7 @@ fn execute_undelegate( delegate: String, ) -> Result { nonpayable(&info)?; + ensure_setup(deps.as_ref())?; let delegator = info.sender; let delegate = deps.api.addr_validate(&delegate)?; @@ -257,6 +288,13 @@ fn execute_undelegate( // remove delegated VP from delegate's total delegated VP let current_delegated_vp = DELEGATED_VP_AMOUNTS.load(deps.storage, (&delegator, &delegate))?; + // this `update` function loads the latest delegated VP, even if it was + // updated before in this block, and then saves the new total at the current + // block, which will be reflected in historical queries starting from the + // NEXT block. if future delegations/undelegations/voting power changes + // occur in this block, they will immediately load the latest state, and + // update the total that will be reflected in historical queries starting + // from the next block. DELEGATED_VP.update( deps.storage, &delegate, @@ -274,89 +312,89 @@ fn execute_undelegate( Ok(Response::new()) } -fn execute_update_config( +fn execute_update_voting_power_hook_callers( deps: DepsMut, info: MessageInfo, - vp_cap_percent: Option>, + add: Option>, + remove: Option>, ) -> Result { nonpayable(&info)?; - // only the DAO can update the config + // only the DAO can update the voting power hook callers let dao = DAO.load(deps.storage)?; if info.sender != dao { return Err(ContractError::Unauthorized {}); } - let mut config = CONFIG.load(deps.storage)?; + if let Some(add) = add { + for addr in add { + VOTING_POWER_HOOK_CALLERS.save(deps.storage, deps.api.addr_validate(&addr)?, &())?; + } + } - if let Some(vp_cap_percent) = vp_cap_percent { - match vp_cap_percent { - OptionalUpdate::Set(vp_cap_percent) => config.vp_cap_percent = Some(vp_cap_percent), - OptionalUpdate::Clear => config.vp_cap_percent = None, + if let Some(remove) = remove { + for addr in remove { + VOTING_POWER_HOOK_CALLERS.remove(deps.storage, deps.api.addr_validate(&addr)?); } } - CONFIG.save(deps.storage, &config)?; + Ok(Response::new().add_attribute("action", "update_voting_power_hook_callers")) +} - Ok(Response::new()) +fn execute_sync_proposal_modules( + deps: DepsMut, + start_after: Option, + limit: Option, +) -> Result { + let dao = DAO.load(deps.storage)?; + let proposal_modules: Vec = deps.querier.query_wasm_smart( + dao, + &dao_interface::msg::QueryMsg::ProposalModules { start_after, limit }, + )?; + + let mut enabled = 0; + let mut disabled = 0; + for proposal_module in proposal_modules { + if proposal_module.status == ProposalModuleStatus::Enabled { + enabled += 1; + PROPOSAL_HOOK_CALLERS.save(deps.storage, proposal_module.address, &())?; + } else { + disabled += 1; + PROPOSAL_HOOK_CALLERS.remove(deps.storage, proposal_module.address); + } + } + + Ok(Response::new() + .add_attribute("action", "sync_proposal_modules") + .add_attribute("enabled", enabled.to_string()) + .add_attribute("disabled", disabled.to_string())) } -pub fn execute_vote_hook( +fn execute_update_config( deps: DepsMut, - env: Env, info: MessageInfo, - vote_hook: VoteHookMsg, + vp_cap_percent: Option>, ) -> Result { - let proposal_module = info.sender; + nonpayable(&info)?; - // TODO: validate proposal module + // only the DAO can update the config + let dao = DAO.load(deps.storage)?; + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } - match vote_hook { - VoteHookMsg::NewVote { - proposal_id, - voter, - power, - height, - is_first_vote, - .. - } => { - // if first vote, update the unvoted delegated VP for their - // delegates by subtracting. if not first vote, this has already - // been done. - if is_first_vote { - let delegator = deps.api.addr_validate(&voter)?; - let delegates = DELEGATIONS.load_all(deps.storage, &delegator, env.block.height)?; - for LoadedItem { - item: Delegation { delegate, percent }, - .. - } in delegates - { - let udvp = get_udvp( - deps.as_ref(), - &delegate, - &proposal_module, - proposal_id, - height, - )?; - - let delegated_vp = calculate_delegated_vp(power, percent); - - // remove the delegator's delegated VP from the delegate's - // unvoted delegated VP for this proposal since this - // delegator just voted. - let new_udvp = udvp.checked_sub(delegated_vp)?; - - UNVOTED_DELEGATED_VP.save( - deps.storage, - (&delegate, &proposal_module, proposal_id), - &new_udvp, - )?; - } - } + let mut config = CONFIG.load(deps.storage)?; + + if let Some(vp_cap_percent) = vp_cap_percent { + match vp_cap_percent { + OptionalUpdate::Set(vp_cap_percent) => config.vp_cap_percent = Some(vp_cap_percent), + OptionalUpdate::Clear => config.vp_cap_percent = None, } } - Ok(Response::new().add_attribute("action", "vote_hook")) + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new()) } #[cfg_attr(not(feature = "library"), entry_point)] @@ -386,6 +424,12 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { proposal_id, height, )?)?), + QueryMsg::ProposalModules { start_after, limit } => Ok(to_json_binary( + &query_proposal_modules(deps, start_after, limit)?, + )?), + QueryMsg::VotingPowerHookCallers { start_after, limit } => Ok(to_json_binary( + &query_voting_power_hook_callers(deps, start_after, limit)?, + )?), } } @@ -459,6 +503,38 @@ fn query_unvoted_delegated_vp( get_udvp(deps, &delegate, &proposal_module, proposal_id, height) } +fn query_proposal_modules( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult> { + paginate_map_keys( + deps, + &PROPOSAL_HOOK_CALLERS, + start_after + .map(|s| deps.api.addr_validate(&s)) + .transpose()?, + limit, + Order::Ascending, + ) +} + +fn query_voting_power_hook_callers( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult> { + paginate_map_keys( + deps, + &VOTING_POWER_HOOK_CALLERS, + start_after + .map(|s| deps.api.addr_validate(&s)) + .transpose()?, + limit, + Order::Ascending, + ) +} + #[cfg_attr(not(feature = "library"), entry_point)] pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { let contract_version = get_contract_version(deps.storage)?; diff --git a/contracts/delegation/dao-vote-delegation/src/error.rs b/contracts/delegation/dao-vote-delegation/src/error.rs index 1c993b435..b9d1641d0 100644 --- a/contracts/delegation/dao-vote-delegation/src/error.rs +++ b/contracts/delegation/dao-vote-delegation/src/error.rs @@ -19,9 +19,15 @@ pub enum ContractError { #[error("semver parsing error: {0}")] SemVer(String), + #[error("delegation module not setup. ensure voting power hook callers are registered and proposal modules are synced.")] + DelegationModuleNotSetup {}, + #[error("unauthorized")] Unauthorized {}, + #[error("unauthorized hook caller")] + UnauthorizedHookCaller {}, + #[error("delegate already registered")] DelegateAlreadyRegistered {}, diff --git a/contracts/delegation/dao-vote-delegation/src/helpers.rs b/contracts/delegation/dao-vote-delegation/src/helpers.rs index b2c491745..e5227d7fb 100644 --- a/contracts/delegation/dao-vote-delegation/src/helpers.rs +++ b/contracts/delegation/dao-vote-delegation/src/helpers.rs @@ -1,8 +1,18 @@ -use cosmwasm_std::{Addr, Decimal, Deps, StdResult, Uint128}; +use cosmwasm_std::{Addr, Decimal, Deps, DepsMut, StdResult, Uint128}; use dao_interface::voting; -use crate::state::{DAO, DELEGATED_VP, DELEGATES, UNVOTED_DELEGATED_VP}; +use crate::{ + state::{ + DAO, DELEGATED_VP, DELEGATES, PROPOSAL_HOOK_CALLERS, UNVOTED_DELEGATED_VP, + VOTING_POWER_HOOK_CALLERS, + }, + ContractError, +}; + +pub fn unregister_delegate(deps: DepsMut, delegate: &Addr, height: u64) -> StdResult<()> { + DELEGATES.remove(deps.storage, delegate.clone(), height) +} pub fn is_delegate_registered(deps: Deps, delegate: &Addr, height: Option) -> StdResult { let option = if let Some(height) = height { @@ -28,9 +38,8 @@ pub fn get_voting_power(deps: Deps, addr: &Addr, height: u64) -> StdResult Uint128 { - if percent.is_zero() { + if percent.is_zero() || vp.is_zero() { return Uint128::zero(); } @@ -61,3 +70,14 @@ pub fn get_udvp( .unwrap_or_default()), } } + +/// Ensures the delegation module is setup correctly. +pub fn ensure_setup(deps: Deps) -> Result<(), ContractError> { + if VOTING_POWER_HOOK_CALLERS.is_empty(deps.storage) + || PROPOSAL_HOOK_CALLERS.is_empty(deps.storage) + { + return Err(ContractError::DelegationModuleNotSetup {}); + } + + Ok(()) +} diff --git a/contracts/delegation/dao-vote-delegation/src/hooks.rs b/contracts/delegation/dao-vote-delegation/src/hooks.rs new file mode 100644 index 000000000..a588e128c --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/hooks.rs @@ -0,0 +1,221 @@ +use cosmwasm_std::{Addr, DepsMut, Env, MessageInfo, Response, StdError, StdResult, Uint128}; +use cw4::MemberChangedHookMsg; +use cw_snapshot_vector_map::LoadedItem; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg, vote::VoteHookMsg}; + +use crate::{ + helpers::{ + calculate_delegated_vp, get_udvp, get_voting_power, is_delegate_registered, + unregister_delegate, + }, + state::{ + Delegation, DELEGATED_VP, DELEGATED_VP_AMOUNTS, DELEGATIONS, PROPOSAL_HOOK_CALLERS, + UNVOTED_DELEGATED_VP, VOTING_POWER_HOOK_CALLERS, + }, + ContractError, +}; + +pub(crate) fn execute_stake_changed( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: StakeChangedHookMsg, +) -> Result { + // ensure voting power hook caller is registered + if !VOTING_POWER_HOOK_CALLERS.has(deps.storage, info.sender.clone()) { + return Err(ContractError::UnauthorizedHookCaller {}); + } + + match msg { + StakeChangedHookMsg::Stake { addr, .. } => { + handle_voting_power_changed_hook(deps, &env, addr) + } + StakeChangedHookMsg::Unstake { addr, .. } => { + handle_voting_power_changed_hook(deps, &env, addr) + } + } +} + +pub(crate) fn execute_membership_changed( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: MemberChangedHookMsg, +) -> Result { + // ensure voting power hook caller is registered + if !VOTING_POWER_HOOK_CALLERS.has(deps.storage, info.sender.clone()) { + return Err(ContractError::UnauthorizedHookCaller {}); + } + + // Get the members whose voting power changed and update their voting power. + for member in msg.diffs { + let addr = deps.api.addr_validate(&member.key)?; + handle_voting_power_changed_hook(deps.branch(), &env, addr)?; + } + + Ok(Response::new().add_attribute("action", "voting_power_change_hook")) +} + +pub(crate) fn execute_nft_stake_changed( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: NftStakeChangedHookMsg, +) -> Result { + // ensure voting power hook caller is registered + if !VOTING_POWER_HOOK_CALLERS.has(deps.storage, info.sender.clone()) { + return Err(ContractError::UnauthorizedHookCaller {}); + } + + match msg { + NftStakeChangedHookMsg::Stake { addr, .. } => { + handle_voting_power_changed_hook(deps, &env, addr) + } + NftStakeChangedHookMsg::Unstake { addr, .. } => { + handle_voting_power_changed_hook(deps, &env, addr) + } + } +} + +/// Perform necessary updates when a member's voting power changes. +/// +/// For delegators: +/// - update their delegated VP for each delegate +/// - update each delegate's total delegated VP +/// +/// For delegates: +/// - unregister them if they have no voting power +/// - TODO: re-register them if previously registered but had no voting power??? +pub(crate) fn handle_voting_power_changed_hook( + deps: DepsMut, + env: &Env, + addr: Addr, +) -> Result { + let new_vp = get_voting_power( + deps.as_ref(), + &addr, + // use next block height since voting power takes effect at the start of + // the next block. since the member changed their voting power in the + // current block, we need to use the new value. + env.block.height + 1, + )?; + + // check latest state instead of historical height, since we need access to + // immediate updates made earlier in the same block + if is_delegate_registered(deps.as_ref(), &addr, None)? { + let delegate = addr; + + // unregister if no more voting power + if new_vp.is_zero() { + unregister_delegate(deps, &delegate, env.block.height)?; + } + } + // if not a delegate, check if they have any delegations, and update + // delegate VPs accordingly + else { + let delegator = addr; + + // need to get the latest delegations in case any were updated earlier + // in the same block + let delegations = + DELEGATIONS.load_all_latest(deps.storage, &delegator, env.block.height)?; + + for LoadedItem { + item: Delegation { delegate, percent }, + .. + } in delegations + { + // remove the current delegated VP from the delegate's total and + // replace it with the new delegated VP + let current_delegated_vp = + DELEGATED_VP_AMOUNTS.load(deps.storage, (&delegator, &delegate))?; + let new_delegated_vp = calculate_delegated_vp(new_vp, percent); + + // this `update` function loads the latest delegated VP, even if it + // was updated before in this block, and then saves the new total at + // the current block, which will be reflected in historical queries + // starting from the NEXT block. if future + // delegations/undelegations/voting power changes occur in this + // block, they will immediately load the latest state, and update + // the total that will be reflected in historical queries starting + // from the next block. + DELEGATED_VP.update( + deps.storage, + &delegate, + env.block.height, + |vp| -> StdResult { + Ok(vp + .unwrap_or_default() + .checked_sub(current_delegated_vp) + .map_err(StdError::overflow)? + .checked_add(new_delegated_vp) + .map_err(StdError::overflow)?) + }, + )?; + DELEGATED_VP_AMOUNTS.save(deps.storage, (&delegator, &delegate), &new_delegated_vp)?; + } + } + + Ok(Response::new().add_attribute("action", "voting_power_change_hook")) +} + +pub fn execute_vote_hook( + deps: DepsMut, + env: Env, + info: MessageInfo, + vote_hook: VoteHookMsg, +) -> Result { + let proposal_module = info.sender; + + // ensure proposal module is registered + if !PROPOSAL_HOOK_CALLERS.has(deps.storage, proposal_module.clone()) { + return Err(ContractError::UnauthorizedHookCaller {}); + } + + match vote_hook { + VoteHookMsg::NewVote { + proposal_id, + voter, + power, + height, + is_first_vote, + .. + } => { + // if first vote, update the unvoted delegated VP for their + // delegates by subtracting. if not first vote, this has already + // been done. + if is_first_vote { + let delegator = deps.api.addr_validate(&voter)?; + let delegates = DELEGATIONS.load_all(deps.storage, &delegator, env.block.height)?; + for LoadedItem { + item: Delegation { delegate, percent }, + .. + } in delegates + { + let udvp = get_udvp( + deps.as_ref(), + &delegate, + &proposal_module, + proposal_id, + height, + )?; + + let delegated_vp = calculate_delegated_vp(power, percent); + + // remove the delegator's delegated VP from the delegate's + // unvoted delegated VP for this proposal since this + // delegator just voted. + let new_udvp = udvp.checked_sub(delegated_vp)?; + + UNVOTED_DELEGATED_VP.save( + deps.storage, + (&delegate, &proposal_module, proposal_id), + &new_udvp, + )?; + } + } + } + } + + Ok(Response::new().add_attribute("action", "vote_hook")) +} diff --git a/contracts/delegation/dao-vote-delegation/src/lib.rs b/contracts/delegation/dao-vote-delegation/src/lib.rs index c7e09cbe7..167195f08 100644 --- a/contracts/delegation/dao-vote-delegation/src/lib.rs +++ b/contracts/delegation/dao-vote-delegation/src/lib.rs @@ -3,6 +3,7 @@ pub mod contract; mod error; mod helpers; +mod hooks; pub mod msg; pub mod state; diff --git a/contracts/delegation/dao-vote-delegation/src/msg.rs b/contracts/delegation/dao-vote-delegation/src/msg.rs index 1a4c208fc..97cd2c7b0 100644 --- a/contracts/delegation/dao-vote-delegation/src/msg.rs +++ b/contracts/delegation/dao-vote-delegation/src/msg.rs @@ -10,6 +10,14 @@ use crate::state::Delegation; pub struct InstantiateMsg { /// The DAO. If not provided, the instantiator is used. pub dao: Option, + /// The authorized voting power changed hook callers. + pub vp_hook_callers: Option>, + /// Whether or not to sync proposal modules initially. If there are too + /// many, the instantiation will run out of gas, so this should be disabled + /// and `SyncProposalModules` called manually. + /// + /// Defaults to false. + pub no_sync_proposal_modules: Option, /// the maximum percent of voting power that a single delegate can wield. /// they can be delegated any amount of voting power—this cap is only /// applied when casting votes. @@ -37,6 +45,22 @@ pub enum ExecuteMsg { /// the delegate to undelegate from delegate: String, }, + /// Update the authorized voting power changed hook callers. + UpdateVotingPowerHookCallers { + /// the addresses to add. + add: Option>, + /// the addresses to remove. + remove: Option>, + }, + /// Sync the active proposal modules from the DAO. Can be called by anyone. + SyncProposalModules { + /// the proposal module to start after, if any. passed through to the + /// DAO proposal modules query. + start_after: Option, + /// the maximum number of proposal modules to return. passed through to + /// the DAO proposal modules query. + limit: Option, + }, /// Updates the configuration of the delegation system. UpdateConfig { /// the maximum percent of voting power that a single delegate can @@ -93,6 +117,18 @@ pub enum QueryMsg { proposal_id: u64, height: u64, }, + /// Returns the proposal modules synced from the DAO. + #[returns(Vec)] + ProposalModules { + start_after: Option, + limit: Option, + }, + /// Returns the voting power hook callers. + #[returns(Vec)] + VotingPowerHookCallers { + start_after: Option, + limit: Option, + }, } #[cw_serde] diff --git a/contracts/delegation/dao-vote-delegation/src/state.rs b/contracts/delegation/dao-vote-delegation/src/state.rs index 18cfde514..4ba423c2d 100644 --- a/contracts/delegation/dao-vote-delegation/src/state.rs +++ b/contracts/delegation/dao-vote-delegation/src/state.rs @@ -9,6 +9,14 @@ pub const CONFIG: Item = Item::new("config"); /// the DAO this delegation system is connected to. pub const DAO: Item = Item::new("dao"); +/// the active proposal modules loaded from the DAO that can execute +/// proposal-related hooks. +pub const PROPOSAL_HOOK_CALLERS: Map = Map::new("dpm"); + +/// the contracts that can execute the voting power change hooks. these should +/// be DAO voting modules or their associated staking contracts. +pub const VOTING_POWER_HOOK_CALLERS: Map = Map::new("vphc"); + /// the delegates. pub const DELEGATES: SnapshotMap = SnapshotMap::new( "delegates", @@ -45,7 +53,7 @@ pub const DELEGATIONS: SnapshotVectorMap = SnapshotVectorMap:: pub const DELEGATION_IDS: Map<(&Addr, &Addr), u64> = Map::new("dids"); /// map (delegator, delegate) -> calculated absolute delegated VP. -pub const DELEGATED_VP_AMOUNTS: Map<(&Addr, &Addr), Uint128> = Map::new("dvp_amounts"); +pub const DELEGATED_VP_AMOUNTS: Map<(&Addr, &Addr), Uint128> = Map::new("dvpa"); /// map delegator -> percent delegated to all delegates. pub const PERCENT_DELEGATED: Map<&Addr, Decimal> = Map::new("pd"); @@ -56,6 +64,7 @@ pub struct Config { /// they can be delegated any amount of voting power—this cap is only /// applied when casting votes. pub vp_cap_percent: Option, + // TODO: expiry?? wormhole???? // /// the duration a delegation is valid for, after which it must be renewed // /// by the delegator. // pub delegation_validity: Option, diff --git a/packages/cw-snapshot-vector-map/src/lib.rs b/packages/cw-snapshot-vector-map/src/lib.rs index 4b106831f..3711b9877 100644 --- a/packages/cw-snapshot-vector-map/src/lib.rs +++ b/packages/cw-snapshot-vector-map/src/lib.rs @@ -145,7 +145,9 @@ where /// Loads paged items at the given block height that are not expired. This /// takes 1 block to reflect updates made earlier in the same block, due to - /// how [`SnapshotMap`] is implemented. + /// how [`SnapshotMap`] is implemented. When accessing historical data, you + /// probably want to use this function. Use [`Self::load_latest`] to access + /// the latest updates immediately. pub fn load( &self, store: &dyn Storage, @@ -193,6 +195,57 @@ where self.load(store, k, height, None, None) } + /// Loads paged items at the latest block height that are not expired. This + /// reflects updates immediately, including those made earlier in the same + /// block, in contrast to [`Self::load`]. When you need to access data + /// potentially updated in the current block, use this function. + /// + /// **NOTE: The caller is responsible for ensuring the current height is + /// correct, as it is used for checking expiration.** + pub fn load_latest( + &self, + store: &dyn Storage, + k: &K, + current_height: u64, + limit: Option, + offset: Option, + ) -> StdResult>> { + let offset = offset.unwrap_or_default() as usize; + let limit = limit.unwrap_or(u64::MAX) as usize; + + let active_ids = self.active.may_load(store, k.clone())?.unwrap_or_default(); + + // load paged items, skipping expired ones + let items = active_ids + .iter() + .filter(|(_, expiration)| expiration.map_or(true, |exp| exp > current_height)) + .skip(offset) + .take(limit) + .map(|(id, expiration)| -> StdResult> { + let item = self.load_item(store, k, *id)?; + Ok(LoadedItem { + id: *id, + item, + expiration: *expiration, + }) + }) + .collect::>>()?; + + Ok(items) + } + + /// Loads all items at the given block height that are not expired. This + /// takes 1 block to reflect updates made earlier in the same block, due to + /// how [`SnapshotMap`] is implemented. + pub fn load_all_latest( + &self, + store: &dyn Storage, + k: &K, + current_height: u64, + ) -> StdResult>> { + self.load_latest(store, k, current_height, None, None) + } + /// Loads an item from the vector by ID. pub fn load_item(&self, store: &dyn Storage, k: &K, id: u64) -> StdResult { let item = self.items.load(store, &(k.clone(), id))?; From 642e6201adea35f4844a57b6a4a51116945718d3 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sat, 12 Oct 2024 17:31:37 -0400 Subject: [PATCH 07/24] added ability to update existing delegation --- .../dao-vote-delegation/src/contract.rs | 80 ++++++++++++------- .../dao-vote-delegation/src/error.rs | 7 +- .../delegation/dao-vote-delegation/src/msg.rs | 2 +- 3 files changed, 56 insertions(+), 33 deletions(-) diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 2128edca0..4a125bb41 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -163,7 +163,7 @@ fn execute_delegate( nonpayable(&info)?; ensure_setup(deps.as_ref())?; - if percent <= Decimal::zero() { + if percent <= Decimal::zero() || percent > Decimal::one() { return Err(ContractError::InvalidVotingPowerPercent {}); } @@ -185,12 +185,6 @@ fn execute_delegate( return Err(ContractError::DelegateNotRegistered {}); } - // prevent duplicate delegation - let delegation_exists = DELEGATION_IDS.has(deps.storage, (&delegator, &delegate)); - if delegation_exists { - return Err(ContractError::DelegationAlreadyExists {}); - } - // ensure delegator has voting power in the DAO let vp = get_voting_power( deps.as_ref(), @@ -204,37 +198,64 @@ fn execute_delegate( return Err(ContractError::NoVotingPower {}); } - // ensure not delegating more than 100% let current_percent_delegated = PERCENT_DELEGATED .may_load(deps.storage, &delegator)? .unwrap_or_default(); - let new_percent_delegated = current_percent_delegated.checked_add(percent)?; + + let existing_delegation_id = DELEGATION_IDS.may_load(deps.storage, (&delegator, &delegate))?; + + // will be set below, differing based on whether this is a new delegation or + // an update to an existing one + let new_percent_delegated: Decimal; + let current_delegated_vp: Uint128; + + // update an existing delegation + if let Some(existing_delegation_id) = existing_delegation_id { + let existing_delegation = + DELEGATIONS.load_item(deps.storage, &delegator, existing_delegation_id)?; + // remove existing percent and replace with new percent + new_percent_delegated = current_percent_delegated + .checked_sub(existing_delegation.percent)? + .checked_add(percent)?; + + current_delegated_vp = DELEGATED_VP_AMOUNTS.load(deps.storage, (&delegator, &delegate))?; + } + // create a new delegation + else { + new_percent_delegated = current_percent_delegated.checked_add(percent)?; + current_delegated_vp = Uint128::zero(); + + // add new delegation + let delegation_id = DELEGATIONS.push( + deps.storage, + &delegator, + &Delegation { + delegate: delegate.clone(), + percent, + }, + env.block.height, + // TODO: expiry?? + None, + )?; + DELEGATION_IDS.save(deps.storage, (&delegator, &delegate), &delegation_id)?; + } + + // ensure not delegating more than 100% if new_percent_delegated > Decimal::one() { return Err(ContractError::CannotDelegateMoreThan100Percent { current: current_percent_delegated .checked_mul(Decimal::new(100u128.into()))? .to_string(), + attempt: new_percent_delegated + .checked_mul(Decimal::new(100u128.into()))? + .to_string(), }); } - // add new delegation - let delegation_id = DELEGATIONS.push( - deps.storage, - &delegator, - &Delegation { - delegate: delegate.clone(), - percent, - }, - env.block.height, - // TODO: expiry?? - None, - )?; - - DELEGATION_IDS.save(deps.storage, (&delegator, &delegate), &delegation_id)?; PERCENT_DELEGATED.save(deps.storage, &delegator, &new_percent_delegated)?; - // add the delegated VP to the delegate's total delegated VP - let delegated_vp = calculate_delegated_vp(vp, percent); + // calculate the new delegated VP and add to the delegate's total + let new_delegated_vp = calculate_delegated_vp(vp, percent); // this `update` function loads the latest delegated VP, even if it was // updated before in this block, and then saves the new total at the current // block, which will be reflected in historical queries starting from the @@ -249,11 +270,16 @@ fn execute_delegate( |vp| -> StdResult { Ok(vp .unwrap_or_default() - .checked_add(delegated_vp) + // remove the current delegated VP from the delegate's total and + // replace it with the new delegated VP. if this is a new + // delegation, this will be zero. + .checked_sub(current_delegated_vp) + .map_err(StdError::overflow)? + .checked_add(new_delegated_vp) .map_err(StdError::overflow)?) }, )?; - DELEGATED_VP_AMOUNTS.save(deps.storage, (&delegator, &delegate), &delegated_vp)?; + DELEGATED_VP_AMOUNTS.save(deps.storage, (&delegator, &delegate), &new_delegated_vp)?; Ok(Response::new()) } diff --git a/contracts/delegation/dao-vote-delegation/src/error.rs b/contracts/delegation/dao-vote-delegation/src/error.rs index b9d1641d0..83d830f16 100644 --- a/contracts/delegation/dao-vote-delegation/src/error.rs +++ b/contracts/delegation/dao-vote-delegation/src/error.rs @@ -46,14 +46,11 @@ pub enum ContractError { #[error("cannot delegate to self")] CannotDelegateToSelf {}, - #[error("delegation already exists")] - DelegationAlreadyExists {}, - #[error("delegation does not exist")] DelegationDoesNotExist {}, - #[error("cannot delegate more than 100% (current: {current}%)")] - CannotDelegateMoreThan100Percent { current: String }, + #[error("cannot delegate more than 100% (current: {current}%, attempt: {attempt}%)")] + CannotDelegateMoreThan100Percent { current: String, attempt: String }, #[error("invalid voting power percent")] InvalidVotingPowerPercent {}, diff --git a/contracts/delegation/dao-vote-delegation/src/msg.rs b/contracts/delegation/dao-vote-delegation/src/msg.rs index 97cd2c7b0..76440a64b 100644 --- a/contracts/delegation/dao-vote-delegation/src/msg.rs +++ b/contracts/delegation/dao-vote-delegation/src/msg.rs @@ -33,7 +33,7 @@ pub enum ExecuteMsg { Register {}, /// Unregister as a delegate. Unregister {}, - /// Create a delegation. + /// Create a delegation or update an existing one. Delegate { /// the delegate to delegate to delegate: String, From b9873d031c8cb76d382139ab70479d48b627ea17 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sat, 12 Oct 2024 17:33:13 -0400 Subject: [PATCH 08/24] clippy --- contracts/delegation/dao-vote-delegation/src/contract.rs | 9 ++++----- contracts/delegation/dao-vote-delegation/src/helpers.rs | 4 ++-- contracts/delegation/dao-vote-delegation/src/hooks.rs | 5 ++--- contracts/delegation/dao-vote-delegation/src/lib.rs | 4 ++-- packages/dao-hooks/src/vote.rs | 1 + 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 4a125bb41..63e235bdc 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -268,15 +268,14 @@ fn execute_delegate( &delegate, env.block.height, |vp| -> StdResult { - Ok(vp - .unwrap_or_default() + vp.unwrap_or_default() // remove the current delegated VP from the delegate's total and // replace it with the new delegated VP. if this is a new // delegation, this will be zero. .checked_sub(current_delegated_vp) .map_err(StdError::overflow)? .checked_add(new_delegated_vp) - .map_err(StdError::overflow)?) + .map_err(StdError::overflow) }, )?; DELEGATED_VP_AMOUNTS.save(deps.storage, (&delegator, &delegate), &new_delegated_vp)?; @@ -326,11 +325,11 @@ fn execute_undelegate( &delegate, env.block.height, |vp| -> StdResult { - Ok(vp + vp // must exist if delegation was added in the past .ok_or(StdError::not_found("delegate's total delegated VP"))? .checked_sub(current_delegated_vp) - .map_err(StdError::overflow)?) + .map_err(StdError::overflow) }, )?; DELEGATED_VP_AMOUNTS.remove(deps.storage, (&delegator, &delegate)); diff --git a/contracts/delegation/dao-vote-delegation/src/helpers.rs b/contracts/delegation/dao-vote-delegation/src/helpers.rs index e5227d7fb..4b6858a02 100644 --- a/contracts/delegation/dao-vote-delegation/src/helpers.rs +++ b/contracts/delegation/dao-vote-delegation/src/helpers.rs @@ -63,10 +63,10 @@ pub fn get_udvp( // total delegated VP at that height. UNVOTED_DELEGATED_VP gets set when the // delegate or one of their delegators casts a vote. if empty, none of them // have voted yet. - match UNVOTED_DELEGATED_VP.may_load(deps.storage, (&delegate, &proposal_module, proposal_id))? { + match UNVOTED_DELEGATED_VP.may_load(deps.storage, (delegate, proposal_module, proposal_id))? { Some(vp) => Ok(vp), None => Ok(DELEGATED_VP - .may_load_at_height(deps.storage, &delegate, height)? + .may_load_at_height(deps.storage, delegate, height)? .unwrap_or_default()), } } diff --git a/contracts/delegation/dao-vote-delegation/src/hooks.rs b/contracts/delegation/dao-vote-delegation/src/hooks.rs index a588e128c..ad8cc1eaf 100644 --- a/contracts/delegation/dao-vote-delegation/src/hooks.rs +++ b/contracts/delegation/dao-vote-delegation/src/hooks.rs @@ -144,12 +144,11 @@ pub(crate) fn handle_voting_power_changed_hook( &delegate, env.block.height, |vp| -> StdResult { - Ok(vp - .unwrap_or_default() + vp.unwrap_or_default() .checked_sub(current_delegated_vp) .map_err(StdError::overflow)? .checked_add(new_delegated_vp) - .map_err(StdError::overflow)?) + .map_err(StdError::overflow) }, )?; DELEGATED_VP_AMOUNTS.save(deps.storage, (&delegator, &delegate), &new_delegated_vp)?; diff --git a/contracts/delegation/dao-vote-delegation/src/lib.rs b/contracts/delegation/dao-vote-delegation/src/lib.rs index 167195f08..5e7eaa5cc 100644 --- a/contracts/delegation/dao-vote-delegation/src/lib.rs +++ b/contracts/delegation/dao-vote-delegation/src/lib.rs @@ -7,7 +7,7 @@ mod hooks; pub mod msg; pub mod state; -#[cfg(test)] -mod testing; +// #[cfg(test)] +// mod testing; pub use crate::error::ContractError; diff --git a/packages/dao-hooks/src/vote.rs b/packages/dao-hooks/src/vote.rs index 97bdc1f05..b9cddb169 100644 --- a/packages/dao-hooks/src/vote.rs +++ b/packages/dao-hooks/src/vote.rs @@ -26,6 +26,7 @@ pub enum VoteHookMsg { /// Prepares new vote hook messages. These messages reply on error /// and have even reply IDs. /// IDs are set to odd numbers to then be interleaved with the proposal hooks. +#[allow(clippy::too_many_arguments)] pub fn new_vote_hooks( hooks: Hooks, storage: &dyn Storage, From 2efd5a5f8f99595a2a98563eba5f5670a83ac7ff Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 14 Oct 2024 00:56:43 -0400 Subject: [PATCH 09/24] added delegated VP logic to single and multiple choice proposal modules and removed redundant state --- Cargo.lock | 2 +- ci/bootstrap-env/src/main.rs | 1 + ci/integration-tests/src/helpers/helper.rs | 1 + .../schema/dao-vote-delegation.json | 941 ++++++++++++++++++ .../dao-vote-delegation/src/contract.rs | 73 +- .../dao-vote-delegation/src/helpers.rs | 23 +- .../dao-vote-delegation/src/hooks.rs | 21 +- .../delegation/dao-vote-delegation/src/msg.rs | 84 +- .../dao-vote-delegation/src/state.rs | 18 +- .../btsg-ft-factory/src/testing/tests.rs | 4 + .../dao-migrator/src/testing/state_helpers.rs | 1 + .../dao-migrator/src/utils/state_queries.rs | 1 + .../src/tests.rs | 3 + .../src/tests/single.rs | 2 + .../dao-pre-propose-multiple/src/tests.rs | 3 + .../dao-pre-propose-single/src/tests.rs | 3 + .../schema/dao-proposal-multiple.json | 94 ++ .../dao-proposal-multiple/src/contract.rs | 86 +- .../dao-proposal-multiple/src/error.rs | 5 +- .../proposal/dao-proposal-multiple/src/msg.rs | 12 + .../dao-proposal-multiple/src/proposal.rs | 4 + .../dao-proposal-multiple/src/state.rs | 3 + .../src/testing/adversarial_tests.rs | 1 + .../src/testing/do_votes.rs | 1 + .../src/testing/instantiate.rs | 2 + .../src/testing/tests.rs | 54 + .../schema/dao-proposal-single.json | 94 ++ .../dao-proposal-single/src/contract.rs | 92 +- .../proposal/dao-proposal-single/src/error.rs | 5 +- .../proposal/dao-proposal-single/src/msg.rs | 10 + .../dao-proposal-single/src/proposal.rs | 4 + .../proposal/dao-proposal-single/src/state.rs | 3 + .../src/testing/adversarial_tests.rs | 2 + .../src/testing/do_votes.rs | 1 + .../src/testing/instantiate.rs | 2 + .../dao-proposal-single/src/testing/tests.rs | 10 +- .../dao-proposal-hook-counter/src/tests.rs | 1 + packages/dao-interface/src/helpers.rs | 35 + packages/dao-interface/src/lib.rs | 1 + packages/dao-voting/src/delegation.rs | 92 ++ packages/dao-voting/src/lib.rs | 1 + packages/dao-voting/src/voting.rs | 51 +- scripts/schema.sh | 10 + 43 files changed, 1697 insertions(+), 160 deletions(-) create mode 100644 contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json create mode 100644 packages/dao-interface/src/helpers.rs create mode 100644 packages/dao-voting/src/delegation.rs diff --git a/Cargo.lock b/Cargo.lock index 4726378b7..9e8b51bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2383,7 +2383,7 @@ dependencies = [ "cosmwasm-std", "cw-controllers 1.1.2", "cw-multi-test", - "cw-paginate-storage 2.5.0", + "cw-paginate-storage 2.6.0", "cw-snapshot-vector-map", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", diff --git a/ci/bootstrap-env/src/main.rs b/ci/bootstrap-env/src/main.rs index be218793f..4d2c3731d 100644 --- a/ci/bootstrap-env/src/main.rs +++ b/ci/bootstrap-env/src/main.rs @@ -115,6 +115,7 @@ fn main() -> Result<()> { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, })?, admin: Some(Admin::CoreModule {}), funds: vec![], diff --git a/ci/integration-tests/src/helpers/helper.rs b/ci/integration-tests/src/helpers/helper.rs index 13b627d4d..548fc59f5 100644 --- a/ci/integration-tests/src/helpers/helper.rs +++ b/ci/integration-tests/src/helpers/helper.rs @@ -98,6 +98,7 @@ pub fn create_dao( }, }, veto: None, + delegation_module: None, })?, admin: Some(Admin::CoreModule {}), funds: vec![], diff --git a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json new file mode 100644 index 000000000..c81e235bd --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json @@ -0,0 +1,941 @@ +{ + "contract_name": "dao-vote-delegation", + "contract_version": "2.5.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "properties": { + "dao": { + "description": "The DAO. If not provided, the instantiator is used.", + "type": [ + "string", + "null" + ] + }, + "no_sync_proposal_modules": { + "description": "Whether or not to sync proposal modules initially. If there are too many, the instantiation will run out of gas, so this should be disabled and `SyncProposalModules` called manually.\n\nDefaults to false.", + "type": [ + "boolean", + "null" + ] + }, + "vp_cap_percent": { + "description": "the maximum percent of voting power that a single delegate can wield. they can be delegated any amount of voting power—this cap is only applied when casting votes.", + "anyOf": [ + { + "$ref": "#/definitions/Decimal" + }, + { + "type": "null" + } + ] + }, + "vp_hook_callers": { + "description": "The authorized voting power changed hook callers.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Register as a delegate.", + "type": "object", + "required": [ + "register" + ], + "properties": { + "register": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Unregister as a delegate.", + "type": "object", + "required": [ + "unregister" + ], + "properties": { + "unregister": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Create a delegation or update an existing one.", + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "type": "object", + "required": [ + "delegate", + "percent" + ], + "properties": { + "delegate": { + "description": "the delegate to delegate to", + "type": "string" + }, + "percent": { + "description": "the percent of voting power to delegate", + "allOf": [ + { + "$ref": "#/definitions/Decimal" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Revoke a delegation.", + "type": "object", + "required": [ + "undelegate" + ], + "properties": { + "undelegate": { + "type": "object", + "required": [ + "delegate" + ], + "properties": { + "delegate": { + "description": "the delegate to undelegate from", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update the authorized voting power changed hook callers.", + "type": "object", + "required": [ + "update_voting_power_hook_callers" + ], + "properties": { + "update_voting_power_hook_callers": { + "type": "object", + "properties": { + "add": { + "description": "the addresses to add.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "remove": { + "description": "the addresses to remove.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sync the active proposal modules from the DAO. Can be called by anyone.", + "type": "object", + "required": [ + "sync_proposal_modules" + ], + "properties": { + "sync_proposal_modules": { + "type": "object", + "properties": { + "limit": { + "description": "the maximum number of proposal modules to return. passed through to the DAO proposal modules query.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "the proposal module to start after, if any. passed through to the DAO proposal modules query.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the configuration of the delegation system.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "required": [ + "vp_cap_percent" + ], + "properties": { + "vp_cap_percent": { + "description": "the maximum percent of voting power that a single delegate can wield. they can be delegated any amount of voting power—this cap is only applied when casting votes.", + "allOf": [ + { + "$ref": "#/definitions/OptionalUpdate_for_Decimal" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Called when a member is added or removed to a cw4-groups or cw721-roles contract.", + "type": "object", + "required": [ + "member_changed_hook" + ], + "properties": { + "member_changed_hook": { + "$ref": "#/definitions/MemberChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Called when NFTs are staked or unstaked.", + "type": "object", + "required": [ + "nft_stake_change_hook" + ], + "properties": { + "nft_stake_change_hook": { + "$ref": "#/definitions/NftStakeChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Called when tokens are staked or unstaked.", + "type": "object", + "required": [ + "stake_change_hook" + ], + "properties": { + "stake_change_hook": { + "$ref": "#/definitions/StakeChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Called when a vote is cast.", + "type": "object", + "required": [ + "vote_hook" + ], + "properties": { + "vote_hook": { + "$ref": "#/definitions/VoteHookMsg" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "MemberChangedHookMsg": { + "description": "MemberChangedHookMsg should be de/serialized under `MemberChangedHook()` variant in a ExecuteMsg. This contains a list of all diffs on the given transaction.", + "type": "object", + "required": [ + "diffs" + ], + "properties": { + "diffs": { + "type": "array", + "items": { + "$ref": "#/definitions/MemberDiff" + } + } + }, + "additionalProperties": false + }, + "MemberDiff": { + "description": "MemberDiff shows the old and new states for a given cw4 member They cannot both be None. old = None, new = Some -> Insert old = Some, new = Some -> Update old = Some, new = None -> Delete", + "type": "object", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + }, + "new": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "old": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "NftStakeChangedHookMsg": { + "description": "An enum representing NFT staking hooks.", + "oneOf": [ + { + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "required": [ + "addr", + "token_id" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "addr", + "token_ids" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "token_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "OptionalUpdate_for_Decimal": { + "description": "An update type that allows partial updates of optional fields.", + "anyOf": [ + { + "$ref": "#/definitions/Update_for_Decimal" + }, + { + "type": "null" + } + ] + }, + "StakeChangedHookMsg": { + "description": "An enum representing staking hooks.", + "oneOf": [ + { + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "required": [ + "addr", + "amount" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "addr", + "amount" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Update_for_Decimal": { + "oneOf": [ + { + "type": "string", + "enum": [ + "clear" + ] + }, + { + "type": "object", + "required": [ + "set" + ], + "properties": { + "set": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + ] + }, + "VoteHookMsg": { + "description": "An enum representing vote hooks, fired when new votes are cast.", + "oneOf": [ + { + "type": "object", + "required": [ + "new_vote" + ], + "properties": { + "new_vote": { + "type": "object", + "required": [ + "height", + "is_first_vote", + "power", + "proposal_id", + "vote", + "voter" + ], + "properties": { + "height": { + "description": "The block height at which the voting power is calculated.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "is_first_vote": { + "description": "Whether this is the first vote cast by this voter on this proposal. This will always be true if revoting is disabled.", + "type": "boolean" + }, + "power": { + "description": "The voting power of the voter.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "proposal_id": { + "description": "The proposal ID that was voted on.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "vote": { + "description": "The vote that was cast.", + "type": "string" + }, + "voter": { + "description": "The voter that cast the vote.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Returns contract version info", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the paginated list of active delegates.", + "type": "object", + "required": [ + "delegates" + ], + "properties": { + "delegates": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the delegations by a delegator, optionally at a given height. Uses the current block height if not provided.", + "type": "object", + "required": [ + "delegations" + ], + "properties": { + "delegations": { + "type": "object", + "required": [ + "delegator" + ], + "properties": { + "delegator": { + "type": "string" + }, + "height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "offset": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the VP delegated to a delegate that has not yet been used in votes cast by delegators in a specific proposal.", + "type": "object", + "required": [ + "unvoted_delegated_voting_power" + ], + "properties": { + "unvoted_delegated_voting_power": { + "type": "object", + "required": [ + "delegate", + "height", + "proposal_id", + "proposal_module" + ], + "properties": { + "delegate": { + "type": "string" + }, + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal_id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "proposal_module": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the proposal modules synced from the DAO.", + "type": "object", + "required": [ + "proposal_modules" + ], + "properties": { + "proposal_modules": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the voting power hook callers.", + "type": "object", + "required": [ + "voting_power_hook_callers" + ], + "properties": { + "voting_power_hook_callers": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "delegates": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DelegatesResponse", + "type": "object", + "required": [ + "delegates" + ], + "properties": { + "delegates": { + "description": "The delegates.", + "type": "array", + "items": { + "$ref": "#/definitions/DelegateResponse" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "DelegateResponse": { + "type": "object", + "required": [ + "delegate", + "power" + ], + "properties": { + "delegate": { + "description": "The delegate.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "power": { + "description": "The total voting power delegated to the delegate.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "delegations": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DelegationsResponse", + "type": "object", + "required": [ + "delegations", + "height" + ], + "properties": { + "delegations": { + "description": "The delegations.", + "type": "array", + "items": { + "$ref": "#/definitions/Delegation" + } + }, + "height": { + "description": "The height at which the delegations were loaded.", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "Delegation": { + "type": "object", + "required": [ + "delegate", + "percent" + ], + "properties": { + "delegate": { + "description": "the delegate that can vote on behalf of the delegator.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "percent": { + "description": "the percent of the delegator's voting power that is delegated to the delegate.", + "allOf": [ + { + "$ref": "#/definitions/Decimal" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "additionalProperties": false, + "definitions": { + "ContractVersion": { + "type": "object", + "required": [ + "contract", + "version" + ], + "properties": { + "contract": { + "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", + "type": "string" + }, + "version": { + "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "proposal_modules": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_Addr", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + }, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } + }, + "unvoted_delegated_voting_power": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Uint128", + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "voting_power_hook_callers": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_Addr", + "type": "array", + "items": { + "$ref": "#/definitions/Addr" + }, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } + } + } +} diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 63e235bdc..7083ca499 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -8,25 +8,25 @@ use cw2::{get_contract_version, set_contract_version}; use cw_paginate_storage::paginate_map_keys; use cw_storage_plus::Bound; use cw_utils::{maybe_addr, nonpayable}; +use dao_interface::helpers::OptionalUpdate; use dao_interface::state::{ProposalModule, ProposalModuleStatus}; use dao_interface::voting::InfoResponse; +use dao_voting::delegation::calculate_delegated_vp; use semver::Version; use crate::helpers::{ - calculate_delegated_vp, ensure_setup, get_udvp, get_voting_power, is_delegate_registered, - unregister_delegate, + ensure_setup, get_udvp, get_voting_power, is_delegate_registered, unregister_delegate, }; use crate::hooks::{ execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, execute_vote_hook, }; use crate::msg::{ DelegateResponse, DelegatesResponse, DelegationsResponse, ExecuteMsg, InstantiateMsg, - MigrateMsg, OptionalUpdate, QueryMsg, + MigrateMsg, QueryMsg, }; use crate::state::{ - Config, Delegate, Delegation, CONFIG, DAO, DELEGATED_VP, DELEGATED_VP_AMOUNTS, DELEGATES, - DELEGATIONS, DELEGATION_IDS, PERCENT_DELEGATED, PROPOSAL_HOOK_CALLERS, - VOTING_POWER_HOOK_CALLERS, + Config, Delegate, Delegation, CONFIG, DAO, DELEGATED_VP, DELEGATES, DELEGATIONS, + DELEGATION_IDS, PERCENT_DELEGATED, PROPOSAL_HOOK_CALLERS, VOTING_POWER_HOOK_CALLERS, }; use crate::ContractError; @@ -206,23 +206,44 @@ fn execute_delegate( // will be set below, differing based on whether this is a new delegation or // an update to an existing one - let new_percent_delegated: Decimal; + let new_total_percent_delegated: Decimal; let current_delegated_vp: Uint128; // update an existing delegation if let Some(existing_delegation_id) = existing_delegation_id { - let existing_delegation = + let mut existing_delegation = DELEGATIONS.load_item(deps.storage, &delegator, existing_delegation_id)?; + // remove existing percent and replace with new percent - new_percent_delegated = current_percent_delegated + new_total_percent_delegated = current_percent_delegated .checked_sub(existing_delegation.percent)? .checked_add(percent)?; - current_delegated_vp = DELEGATED_VP_AMOUNTS.load(deps.storage, (&delegator, &delegate))?; + // compute current delegated VP to replace based on existing percent + // before it's replaced + current_delegated_vp = calculate_delegated_vp(vp, existing_delegation.percent); + + // replace delegation with updated percent + DELEGATIONS.remove( + deps.storage, + &delegator, + existing_delegation_id, + env.block.height, + )?; + existing_delegation.percent = percent; + let new_delegation_id = DELEGATIONS.push( + deps.storage, + &delegator, + &existing_delegation, + env.block.height, + // TODO: expiry?? + None, + )?; + DELEGATION_IDS.save(deps.storage, (&delegator, &delegate), &new_delegation_id)?; } // create a new delegation else { - new_percent_delegated = current_percent_delegated.checked_add(percent)?; + new_total_percent_delegated = current_percent_delegated.checked_add(percent)?; current_delegated_vp = Uint128::zero(); // add new delegation @@ -241,18 +262,18 @@ fn execute_delegate( } // ensure not delegating more than 100% - if new_percent_delegated > Decimal::one() { + if new_total_percent_delegated > Decimal::one() { return Err(ContractError::CannotDelegateMoreThan100Percent { current: current_percent_delegated .checked_mul(Decimal::new(100u128.into()))? .to_string(), - attempt: new_percent_delegated + attempt: new_total_percent_delegated .checked_mul(Decimal::new(100u128.into()))? .to_string(), }); } - PERCENT_DELEGATED.save(deps.storage, &delegator, &new_percent_delegated)?; + PERCENT_DELEGATED.save(deps.storage, &delegator, &new_total_percent_delegated)?; // calculate the new delegated VP and add to the delegate's total let new_delegated_vp = calculate_delegated_vp(vp, percent); @@ -278,7 +299,6 @@ fn execute_delegate( .map_err(StdError::overflow) }, )?; - DELEGATED_VP_AMOUNTS.save(deps.storage, (&delegator, &delegate), &new_delegated_vp)?; Ok(Response::new()) } @@ -311,8 +331,17 @@ fn execute_undelegate( let new_percent_delegated = current_percent_delegated.checked_sub(delegation.percent)?; PERCENT_DELEGATED.save(deps.storage, &delegator, &new_percent_delegated)?; + let vp = get_voting_power( + deps.as_ref(), + &delegator, + // use next block height since voting power takes effect at the start of + // the next block. if the delegator changed their voting power in the + // current block, we need to use the new value. + env.block.height + 1, + )?; + // remove delegated VP from delegate's total delegated VP - let current_delegated_vp = DELEGATED_VP_AMOUNTS.load(deps.storage, (&delegator, &delegate))?; + let current_delegated_vp = calculate_delegated_vp(vp, delegation.percent); // this `update` function loads the latest delegated VP, even if it was // updated before in this block, and then saves the new total at the current // block, which will be reflected in historical queries starting from the @@ -332,7 +361,6 @@ fn execute_undelegate( .map_err(StdError::overflow) }, )?; - DELEGATED_VP_AMOUNTS.remove(deps.storage, (&delegator, &delegate)); Ok(Response::new()) } @@ -398,7 +426,7 @@ fn execute_sync_proposal_modules( fn execute_update_config( deps: DepsMut, info: MessageInfo, - vp_cap_percent: Option>, + vp_cap_percent: OptionalUpdate, ) -> Result { nonpayable(&info)?; @@ -410,12 +438,9 @@ fn execute_update_config( let mut config = CONFIG.load(deps.storage)?; - if let Some(vp_cap_percent) = vp_cap_percent { - match vp_cap_percent { - OptionalUpdate::Set(vp_cap_percent) => config.vp_cap_percent = Some(vp_cap_percent), - OptionalUpdate::Clear => config.vp_cap_percent = None, - } - } + vp_cap_percent.maybe_update(|value| { + config.vp_cap_percent = value; + }); CONFIG.save(deps.storage, &config)?; diff --git a/contracts/delegation/dao-vote-delegation/src/helpers.rs b/contracts/delegation/dao-vote-delegation/src/helpers.rs index 4b6858a02..a712ebc11 100644 --- a/contracts/delegation/dao-vote-delegation/src/helpers.rs +++ b/contracts/delegation/dao-vote-delegation/src/helpers.rs @@ -1,6 +1,6 @@ -use cosmwasm_std::{Addr, Decimal, Deps, DepsMut, StdResult, Uint128}; +use cosmwasm_std::{Addr, Deps, DepsMut, StdResult, Uint128}; -use dao_interface::voting; +use dao_voting::voting; use crate::{ state::{ @@ -26,24 +26,7 @@ pub fn is_delegate_registered(deps: Deps, delegate: &Addr, height: Option) pub fn get_voting_power(deps: Deps, addr: &Addr, height: u64) -> StdResult { let dao = DAO.load(deps.storage)?; - - let voting_power: voting::VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( - &dao, - &voting::Query::VotingPowerAtHeight { - address: addr.to_string(), - height: Some(height), - }, - )?; - - Ok(voting_power.power) -} - -pub fn calculate_delegated_vp(vp: Uint128, percent: Decimal) -> Uint128 { - if percent.is_zero() || vp.is_zero() { - return Uint128::zero(); - } - - vp.mul_floor(percent) + voting::get_voting_power(deps, addr.clone(), &dao, Some(height)) } /// Returns the unvoted delegated VP for a delegate on a proposal, falling back diff --git a/contracts/delegation/dao-vote-delegation/src/hooks.rs b/contracts/delegation/dao-vote-delegation/src/hooks.rs index ad8cc1eaf..7fde3a5cf 100644 --- a/contracts/delegation/dao-vote-delegation/src/hooks.rs +++ b/contracts/delegation/dao-vote-delegation/src/hooks.rs @@ -2,15 +2,13 @@ use cosmwasm_std::{Addr, DepsMut, Env, MessageInfo, Response, StdError, StdResul use cw4::MemberChangedHookMsg; use cw_snapshot_vector_map::LoadedItem; use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg, vote::VoteHookMsg}; +use dao_voting::delegation::calculate_delegated_vp; use crate::{ - helpers::{ - calculate_delegated_vp, get_udvp, get_voting_power, is_delegate_registered, - unregister_delegate, - }, + helpers::{get_udvp, get_voting_power, is_delegate_registered, unregister_delegate}, state::{ - Delegation, DELEGATED_VP, DELEGATED_VP_AMOUNTS, DELEGATIONS, PROPOSAL_HOOK_CALLERS, - UNVOTED_DELEGATED_VP, VOTING_POWER_HOOK_CALLERS, + Delegation, DELEGATED_VP, DELEGATIONS, PROPOSAL_HOOK_CALLERS, UNVOTED_DELEGATED_VP, + VOTING_POWER_HOOK_CALLERS, }, ContractError, }; @@ -91,6 +89,7 @@ pub(crate) fn handle_voting_power_changed_hook( env: &Env, addr: Addr, ) -> Result { + let old_vp = get_voting_power(deps.as_ref(), &addr, env.block.height)?; let new_vp = get_voting_power( deps.as_ref(), &addr, @@ -125,10 +124,9 @@ pub(crate) fn handle_voting_power_changed_hook( .. } in delegations { - // remove the current delegated VP from the delegate's total and + // remove the latest delegated VP from the delegate's total and // replace it with the new delegated VP - let current_delegated_vp = - DELEGATED_VP_AMOUNTS.load(deps.storage, (&delegator, &delegate))?; + let current_delegated_vp = calculate_delegated_vp(old_vp, percent); let new_delegated_vp = calculate_delegated_vp(new_vp, percent); // this `update` function loads the latest delegated VP, even if it @@ -151,7 +149,6 @@ pub(crate) fn handle_voting_power_changed_hook( .map_err(StdError::overflow) }, )?; - DELEGATED_VP_AMOUNTS.save(deps.storage, (&delegator, &delegate), &new_delegated_vp)?; } } @@ -181,8 +178,8 @@ pub fn execute_vote_hook( .. } => { // if first vote, update the unvoted delegated VP for their - // delegates by subtracting. if not first vote, this has already - // been done. + // delegates by subtracting this member's delegated VP. if not first + // vote, this has already been done. if is_first_vote { let delegator = deps.api.addr_validate(&voter)?; let delegates = DELEGATIONS.load_all(deps.storage, &delegator, env.block.height)?; diff --git a/contracts/delegation/dao-vote-delegation/src/msg.rs b/contracts/delegation/dao-vote-delegation/src/msg.rs index 76440a64b..be418e8a4 100644 --- a/contracts/delegation/dao-vote-delegation/src/msg.rs +++ b/contracts/delegation/dao-vote-delegation/src/msg.rs @@ -1,10 +1,13 @@ -use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{Addr, Decimal, Uint128}; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Decimal; use cw4::MemberChangedHookMsg; use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg, vote::VoteHookMsg}; -use dao_interface::voting::InfoResponse; +use dao_interface::helpers::OptionalUpdate; -use crate::state::Delegation; +// make these types directly available to consumers of this crate +pub use dao_voting::delegation::{ + DelegateResponse, DelegatesResponse, DelegationsResponse, QueryMsg, +}; #[cw_serde] pub struct InstantiateMsg { @@ -66,7 +69,7 @@ pub enum ExecuteMsg { /// the maximum percent of voting power that a single delegate can /// wield. they can be delegated any amount of voting power—this cap is /// only applied when casting votes. - vp_cap_percent: Option>, + vp_cap_percent: OptionalUpdate, // /// the duration a delegation is valid for, after which it must be // /// renewed by the delegator. // delegation_validity: Option, @@ -82,76 +85,5 @@ pub enum ExecuteMsg { VoteHook(VoteHookMsg), } -#[cw_serde] -pub enum OptionalUpdate { - Set(T), - Clear, -} - -#[cw_serde] -#[derive(QueryResponses)] -pub enum QueryMsg { - /// Returns contract version info - #[returns(InfoResponse)] - Info {}, - #[returns(DelegatesResponse)] - Delegates { - start_after: Option, - limit: Option, - }, - /// Returns the delegations by a delegator, optionally at a given height. - /// Uses the current block height if not provided. - #[returns(DelegationsResponse)] - Delegations { - delegator: String, - height: Option, - offset: Option, - limit: Option, - }, - /// Returns the VP delegated to a delegate that has not yet been used in - /// votes cast by delegators in a specific proposal. - #[returns(Uint128)] - UnvotedDelegatedVotingPower { - delegate: String, - proposal_module: String, - proposal_id: u64, - height: u64, - }, - /// Returns the proposal modules synced from the DAO. - #[returns(Vec)] - ProposalModules { - start_after: Option, - limit: Option, - }, - /// Returns the voting power hook callers. - #[returns(Vec)] - VotingPowerHookCallers { - start_after: Option, - limit: Option, - }, -} - -#[cw_serde] -pub struct DelegatesResponse { - /// The delegates. - pub delegates: Vec, -} - -#[cw_serde] -pub struct DelegateResponse { - /// The delegate. - pub delegate: Addr, - /// The total voting power delegated to the delegate. - pub power: Uint128, -} - -#[cw_serde] -pub struct DelegationsResponse { - /// The delegations. - pub delegations: Vec, - /// The height at which the delegations were loaded. - pub height: u64, -} - #[cw_serde] pub struct MigrateMsg {} diff --git a/contracts/delegation/dao-vote-delegation/src/state.rs b/contracts/delegation/dao-vote-delegation/src/state.rs index 4ba423c2d..86a577932 100644 --- a/contracts/delegation/dao-vote-delegation/src/state.rs +++ b/contracts/delegation/dao-vote-delegation/src/state.rs @@ -3,6 +3,9 @@ use cosmwasm_std::{Addr, Decimal, Uint128}; use cw_snapshot_vector_map::SnapshotVectorMap; use cw_storage_plus::{Item, Map, SnapshotMap, Strategy}; +// make these types directly available to consumers of this crate +pub use dao_voting::delegation::{Delegate, Delegation}; + /// the configuration of the delegation system. pub const CONFIG: Item = Item::new("config"); @@ -52,9 +55,6 @@ pub const DELEGATIONS: SnapshotVectorMap = SnapshotVectorMap:: /// undelegating. pub const DELEGATION_IDS: Map<(&Addr, &Addr), u64> = Map::new("dids"); -/// map (delegator, delegate) -> calculated absolute delegated VP. -pub const DELEGATED_VP_AMOUNTS: Map<(&Addr, &Addr), Uint128> = Map::new("dvpa"); - /// map delegator -> percent delegated to all delegates. pub const PERCENT_DELEGATED: Map<&Addr, Decimal> = Map::new("pd"); @@ -69,15 +69,3 @@ pub struct Config { // /// by the delegator. // pub delegation_validity: Option, } - -#[cw_serde] -pub struct Delegate {} - -#[cw_serde] -pub struct Delegation { - /// the delegate that can vote on behalf of the delegator. - pub delegate: Addr, - /// the percent of the delegator's voting power that is delegated to the - /// delegate. - pub percent: Decimal, -} diff --git a/contracts/external/btsg-ft-factory/src/testing/tests.rs b/contracts/external/btsg-ft-factory/src/testing/tests.rs index 02d456659..d095b8280 100644 --- a/contracts/external/btsg-ft-factory/src/testing/tests.rs +++ b/contracts/external/btsg-ft-factory/src/testing/tests.rs @@ -84,6 +84,7 @@ fn test_issue_fantoken() -> anyhow::Result<()> { pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, })?, admin: Some(Admin::CoreModule {}), funds: vec![], @@ -186,6 +187,7 @@ fn test_initial_fantoken_balances() -> anyhow::Result<()> { pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, })?, admin: Some(Admin::CoreModule {}), funds: vec![], @@ -293,6 +295,7 @@ fn test_fantoken_minter_and_authority_set_to_dao() -> anyhow::Result<()> { pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, })?, admin: Some(Admin::CoreModule {}), funds: vec![], @@ -450,6 +453,7 @@ fn test_fantoken_can_be_staked() -> anyhow::Result<()> { pre_propose_info: dao_voting::pre_propose::PreProposeInfo::AnyoneMayPropose {}, close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, })?, admin: Some(Admin::CoreModule {}), funds: vec![], diff --git a/contracts/external/dao-migrator/src/testing/state_helpers.rs b/contracts/external/dao-migrator/src/testing/state_helpers.rs index 13dbb82ef..d76a622de 100644 --- a/contracts/external/dao-migrator/src/testing/state_helpers.rs +++ b/contracts/external/dao-migrator/src/testing/state_helpers.rs @@ -55,6 +55,7 @@ pub fn query_proposal_v1( votes: v1_votes_to_v2(proposal.votes), allow_revoting: proposal.allow_revoting, veto: None, + delegation_module: None, }; (proposal_count, proposal) diff --git a/contracts/external/dao-migrator/src/utils/state_queries.rs b/contracts/external/dao-migrator/src/utils/state_queries.rs index 338281d93..88112c9c7 100644 --- a/contracts/external/dao-migrator/src/utils/state_queries.rs +++ b/contracts/external/dao-migrator/src/utils/state_queries.rs @@ -84,6 +84,7 @@ pub fn query_proposal_v1( votes: v1_votes_to_v2(proposal.votes), allow_revoting: proposal.allow_revoting, veto: None, + delegation_module: None, }) }) .collect::, ContractError>>( diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs index 1df4ac65a..340581447 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs @@ -83,6 +83,7 @@ fn get_default_proposal_module_instantiate( }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } } @@ -1702,6 +1703,7 @@ fn test_instantiate_with_zero_native_deposit() { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } }; @@ -1771,6 +1773,7 @@ fn test_instantiate_with_zero_cw20_deposit() { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } }; diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/tests/single.rs b/contracts/pre-propose/dao-pre-propose-approver/src/tests/single.rs index 45b5aa9c5..9a1b24386 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/src/tests/single.rs +++ b/contracts/pre-propose/dao-pre-propose-approver/src/tests/single.rs @@ -83,6 +83,7 @@ fn get_proposal_module_approval_single_instantiate( }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } } @@ -116,6 +117,7 @@ fn get_proposal_module_approver_instantiate( }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } } diff --git a/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs b/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs index e27c14e17..6ba2e1af4 100644 --- a/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs @@ -86,6 +86,7 @@ fn get_default_proposal_module_instantiate( }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } } @@ -1368,6 +1369,7 @@ fn test_instantiate_with_zero_native_deposit() { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } }; @@ -1435,6 +1437,7 @@ fn test_instantiate_with_zero_cw20_deposit() { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } }; diff --git a/contracts/pre-propose/dao-pre-propose-single/src/tests.rs b/contracts/pre-propose/dao-pre-propose-single/src/tests.rs index 8c037240c..cc0d7a563 100644 --- a/contracts/pre-propose/dao-pre-propose-single/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-single/src/tests.rs @@ -82,6 +82,7 @@ fn get_default_proposal_module_instantiate( }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } } @@ -1278,6 +1279,7 @@ fn test_instantiate_with_zero_native_deposit() { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } }; @@ -1345,6 +1347,7 @@ fn test_instantiate_with_zero_cw20_deposit() { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } }; diff --git a/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json b/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json index 43f5f9941..d8fa77620 100644 --- a/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json +++ b/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json @@ -23,6 +23,13 @@ "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", "type": "boolean" }, + "delegation_module": { + "description": "The address of the delegation module to use for this proposal module (if any).", + "type": [ + "string", + "null" + ] + }, "max_voting_period": { "description": "The amount of time a proposal can be voted on before expiring", "allOf": [ @@ -622,6 +629,28 @@ }, "additionalProperties": false }, + { + "description": "Update's the address of the delegation module associated with this proposal module. Only the DAO may call this method.", + "type": "object", + "required": [ + "update_delegation_module" + ], + "properties": { + "update_delegation_module": { + "type": "object", + "required": [ + "module" + ], + "properties": { + "module": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "type": "object", "required": [ @@ -2108,6 +2137,20 @@ }, "additionalProperties": false }, + { + "description": "Gets the address of the delegation module associated with this proposal module (if any).", + "type": "object", + "required": [ + "delegation_module" + ], + "properties": { + "delegation_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Lists all of the consumers of proposal hooks for this module.", "type": "object", @@ -2679,6 +2722,24 @@ "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", "type": "string" }, + "delegation_module": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Nullable_Addr", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } + }, "get_vote": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "VoteResponse", @@ -3450,6 +3511,17 @@ "$ref": "#/definitions/CheckedMultipleChoiceOption" } }, + "delegation_module": { + "description": "The address of the delegation module associated with this proposal (if one existed when the proposal was created).", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "description": { "description": "The main body of the proposal text", "type": "string" @@ -4838,6 +4910,17 @@ "$ref": "#/definitions/CheckedMultipleChoiceOption" } }, + "delegation_module": { + "description": "The address of the delegation module associated with this proposal (if one existed when the proposal was created).", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "description": { "description": "The main body of the proposal text", "type": "string" @@ -6184,6 +6267,17 @@ "$ref": "#/definitions/CheckedMultipleChoiceOption" } }, + "delegation_module": { + "description": "The address of the delegation module associated with this proposal (if one existed when the proposal was created).", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "description": { "description": "The main body of the proposal text", "type": "string" diff --git a/contracts/proposal/dao-proposal-multiple/src/contract.rs b/contracts/proposal/dao-proposal-multiple/src/contract.rs index 9154fe670..4d99a9ea8 100644 --- a/contracts/proposal/dao-proposal-multiple/src/contract.rs +++ b/contracts/proposal/dao-proposal-multiple/src/contract.rs @@ -14,6 +14,8 @@ use dao_hooks::proposal::{ }; use dao_hooks::vote::new_vote_hooks; use dao_interface::voting::IsActiveResponse; +use dao_voting::delegation::{self, calculate_delegated_vp, Delegation}; +use dao_voting::voting::get_voting_power_with_delegation; use dao_voting::{ multiple_choice::{MultipleChoiceVote, MultipleChoiceVotes, VotingStrategy}, pre_propose::{PreProposeInfo, ProposalCreationPolicy}, @@ -26,6 +28,7 @@ use dao_voting::{ voting::{get_total_power, get_voting_power, validate_voting_period}, }; +use crate::state::DELEGATION_MODULE; use crate::{msg::MigrateMsg, state::CREATION_POLICY}; use crate::{ msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, @@ -129,6 +132,9 @@ pub fn execute( ExecuteMsg::UpdatePreProposeInfo { info: new_info } => { execute_update_proposal_creation_policy(deps, info, new_info) } + ExecuteMsg::UpdateDelegationModule { module } => { + execute_update_delegation_module(deps, info, module) + } ExecuteMsg::AddProposalHook { address } => { execute_add_proposal_hook(deps, env, info, address) } @@ -198,6 +204,9 @@ pub fn execute_propose( // Validate options. let checked_multiple_choice_options = choices.into_checked()?.options; + // Delegation module may or may not exist. + let delegation_module = DELEGATION_MODULE.may_load(deps.storage)?; + let expiration = config.max_voting_period.after(&env.block); let total_power = get_total_power(deps.as_ref(), &config.dao, None)?; @@ -217,6 +226,7 @@ pub fn execute_propose( allow_revoting: config.allow_revoting, choices: checked_multiple_choice_options, veto: config.veto, + delegation_module, }; // Update the proposal's status. Addresses case where proposal // expires on the same block as it is created. @@ -392,11 +402,14 @@ pub fn execute_vote( return Err(ContractError::Expired { id: proposal_id }); } - let vote_power = get_voting_power( + let vote_power = get_voting_power_with_delegation( deps.as_ref(), - sender.clone(), + &env.contract.address, + &prop.delegation_module, &config.dao, - Some(prop.start_height), + &sender, + proposal_id, + prop.start_height, )?; if vote_power.is_zero() { return Err(ContractError::NotRegistered {}); @@ -437,6 +450,50 @@ pub fn execute_vote( }), })?; + // DELEGATION VOTE OVERRIDE: if this is the first time this member voted, + // subtract their VP from the vote tally of all of their delegates who + // already voted on this proposal, in order to override their vote with the + // delegator's preference. + // + // we must load all delegations and update each. if this partially fails, + // the vote tallies will be incorrect, so the entire vote transaction should + // fail. we need to prevent this from happening by limiting the number of + // delegations a member can have in order to ensure votes can always be + // cast. + if is_first_vote { + if let Some(delegation_module) = &prop.delegation_module { + let delegations = deps + .querier + .query_wasm_smart::( + delegation_module, + &delegation::QueryMsg::Delegations { + delegator: sender.to_string(), + height: Some(prop.start_height), + offset: None, + limit: None, + }, + // ensure query error gets returned if it fails. + )? + .delegations; + + for Delegation { delegate, percent } in delegations { + // if delegate voted already, untally the VP the delegator + // delegated to them since the delegate's vote is being + // overridden. + if let Some(mut delegate_ballot) = + BALLOTS.may_load(deps.storage, (proposal_id, &delegate))? + { + let delegated_vp = calculate_delegated_vp(vote_power, percent); + + prop.votes.remove_vote(delegate_ballot.vote, delegated_vp)?; + + delegate_ballot.power = delegate_ballot.power.checked_sub(delegated_vp)?; + BALLOTS.save(deps.storage, (proposal_id, &delegate), &delegate_ballot)?; + } + } + } + } + let old_status = prop.status; prop.votes.add_vote(vote, vote_power)?; @@ -703,6 +760,23 @@ pub fn execute_update_proposal_creation_policy( .add_attribute("new_policy", format!("{initial_policy:?}"))) } +pub fn execute_update_delegation_module( + deps: DepsMut, + info: MessageInfo, + module: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + return Err(ContractError::Unauthorized {}); + } + + DELEGATION_MODULE.save(deps.storage, &deps.api.addr_validate(&module)?)?; + + Ok(Response::default() + .add_attribute("action", "update_delegation_module") + .add_attribute("module", module)) +} + pub fn execute_update_rationale( deps: DepsMut, info: MessageInfo, @@ -871,6 +945,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { limit, } => query_reverse_proposals(deps, env, start_before, limit), QueryMsg::ProposalCreationPolicy {} => query_creation_policy(deps), + QueryMsg::DelegationModule {} => query_delegation_module(deps), QueryMsg::ProposalHooks {} => to_json_binary(&PROPOSAL_HOOKS.query_hooks(deps)?), QueryMsg::VoteHooks {} => to_json_binary(&VOTE_HOOKS.query_hooks(deps)?), QueryMsg::Dao {} => query_dao(deps), @@ -897,6 +972,11 @@ pub fn query_creation_policy(deps: Deps) -> StdResult { to_json_binary(&policy) } +pub fn query_delegation_module(deps: Deps) -> StdResult { + let module = DELEGATION_MODULE.may_load(deps.storage)?; + to_json_binary(&module) +} + pub fn query_list_proposals( deps: Deps, env: Env, diff --git a/contracts/proposal/dao-proposal-multiple/src/error.rs b/contracts/proposal/dao-proposal-multiple/src/error.rs index 433103f7d..b0aac1dd4 100644 --- a/contracts/proposal/dao-proposal-multiple/src/error.rs +++ b/contracts/proposal/dao-proposal-multiple/src/error.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::StdError; +use cosmwasm_std::{OverflowError, StdError}; use cw_hooks::HookError; use cw_utils::ParseReplyError; use dao_voting::{reply::error::TagError, threshold::ThresholdError, veto::VetoError}; @@ -18,6 +18,9 @@ pub enum ContractError { #[error(transparent)] VetoError(#[from] VetoError), + #[error(transparent)] + OverflowError(#[from] OverflowError), + #[error("Unauthorized")] Unauthorized {}, diff --git a/contracts/proposal/dao-proposal-multiple/src/msg.rs b/contracts/proposal/dao-proposal-multiple/src/msg.rs index 1ed54db93..0da43c567 100644 --- a/contracts/proposal/dao-proposal-multiple/src/msg.rs +++ b/contracts/proposal/dao-proposal-multiple/src/msg.rs @@ -45,6 +45,9 @@ pub struct InstantiateMsg { /// During this period an oversight account (`veto.vetoer`) can /// veto the proposal. pub veto: Option, + /// The address of the delegation module to use for this proposal module (if + /// any). + pub delegation_module: Option, } #[cw_serde] @@ -131,6 +134,11 @@ pub enum ExecuteMsg { UpdatePreProposeInfo { info: PreProposeInfo, }, + /// Update's the address of the delegation module associated with this + /// proposal module. Only the DAO may call this method. + UpdateDelegationModule { + module: String, + }, AddProposalHook { address: String, }, @@ -184,6 +192,10 @@ pub enum QueryMsg { /// Gets the current proposal creation policy for this module. #[returns(::dao_voting::pre_propose::ProposalCreationPolicy)] ProposalCreationPolicy {}, + /// Gets the address of the delegation module associated with this + /// proposal module (if any). + #[returns(Option<::cosmwasm_std::Addr>)] + DelegationModule {}, /// Lists all of the consumers of proposal hooks for this module. #[returns(::cw_hooks::HooksResponse)] ProposalHooks {}, diff --git a/contracts/proposal/dao-proposal-multiple/src/proposal.rs b/contracts/proposal/dao-proposal-multiple/src/proposal.rs index 454a60762..7a757bea7 100644 --- a/contracts/proposal/dao-proposal-multiple/src/proposal.rs +++ b/contracts/proposal/dao-proposal-multiple/src/proposal.rs @@ -51,6 +51,9 @@ pub struct MultipleChoiceProposal { /// Optional veto configuration. If set to `None`, veto option /// is disabled. Otherwise contains the configuration for veto flow. pub veto: Option, + /// The address of the delegation module associated with this proposal (if + /// one existed when the proposal was created). + pub delegation_module: Option, } pub enum VoteResult { @@ -336,6 +339,7 @@ mod tests { allow_revoting, min_voting_period: None, veto: None, + delegation_module: None, } } diff --git a/contracts/proposal/dao-proposal-multiple/src/state.rs b/contracts/proposal/dao-proposal-multiple/src/state.rs index 2261f6d03..972826dce 100644 --- a/contracts/proposal/dao-proposal-multiple/src/state.rs +++ b/contracts/proposal/dao-proposal-multiple/src/state.rs @@ -72,3 +72,6 @@ pub const VOTE_HOOKS: Hooks = Hooks::new("vote_hooks"); /// The address of the pre-propose module associated with this /// proposal module (if any). pub const CREATION_POLICY: Item = Item::new("creation_policy"); +/// The address of the delegation module associated with this proposal module +/// (if any). +pub const DELEGATION_MODULE: Item = Item::new("delegation_module"); diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs b/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs index 0ffc0ccb1..483b89759 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs @@ -283,6 +283,7 @@ pub fn test_allow_voting_after_proposal_execution_pre_expiration_cw20() { ), close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_multiple_staked_balances_governance( diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs index 959c96da3..61753bb64 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs @@ -131,6 +131,7 @@ where close_proposal_on_execution_failure: true, pre_propose_info, veto: None, + delegation_module: None, }; let governance_addr = setup_governance(&mut app, instantiate, Some(initial_balances)); diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs b/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs index bb0eacb29..7a186c6de 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs @@ -81,6 +81,7 @@ pub fn _get_default_token_dao_proposal_module_instantiate(app: &mut App) -> Inst ), close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, } } @@ -98,6 +99,7 @@ fn _get_default_non_token_dao_proposal_module_instantiate(app: &mut App) -> Inst pre_propose_info: get_pre_propose_info(app, None, false), close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, } } diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs index 5dab858ff..dd3ae3b3d 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs @@ -127,6 +127,7 @@ fn test_propose() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -184,6 +185,7 @@ fn test_propose() { allow_revoting: false, min_voting_period: None, veto: None, + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -209,6 +211,7 @@ fn test_propose_wrong_num_choices() { voting_strategy: voting_strategy.clone(), pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -289,6 +292,7 @@ fn test_proposal_count_initialized_to_zero() { allow_revoting: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, msg, None); @@ -328,6 +332,7 @@ fn test_propose_auto_vote_winner() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -394,6 +399,7 @@ fn test_propose_auto_vote_winner() { allow_revoting: false, min_voting_period: None, veto: None, + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -419,6 +425,7 @@ fn test_propose_auto_vote_reject() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -485,6 +492,7 @@ fn test_propose_auto_vote_reject() { allow_revoting: false, min_voting_period: None, veto: None, + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -511,6 +519,7 @@ fn test_propose_non_member_auto_vote_fail() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -573,6 +582,7 @@ fn test_no_early_pass_with_min_duration() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( @@ -669,6 +679,7 @@ fn test_propose_with_messages() { allow_revoting: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( @@ -791,6 +802,7 @@ fn test_min_duration_units_missmatch() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; instantiate_with_staked_balances_governance( &mut app, @@ -824,6 +836,7 @@ fn test_min_duration_larger_than_proposal_duration() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; instantiate_with_staked_balances_governance( &mut app, @@ -856,6 +869,7 @@ fn test_min_duration_same_as_proposal_duration() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( @@ -980,6 +994,7 @@ fn test_voting_module_token_proposal_deposit_instantiate() { false, ), veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -1056,6 +1071,7 @@ fn test_different_token_proposal_deposit() { false, ), veto: None, + delegation_module: None, }; instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -1118,6 +1134,7 @@ fn test_bad_token_proposal_deposit() { false, ), veto: None, + delegation_module: None, }; instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -1151,6 +1168,7 @@ fn test_take_proposal_deposit() { false, ), veto: None, + delegation_module: None, }; let core_addr = instantiate_with_cw20_balances_governance( @@ -1259,6 +1277,7 @@ fn test_take_native_proposal_deposit() { false, ), veto: None, + delegation_module: None, }; let core_addr = instantiate_with_native_staked_balances_governance( @@ -1353,6 +1372,7 @@ fn test_native_proposal_deposit() { false, ), veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( @@ -1784,6 +1804,7 @@ fn test_cant_propose_zero_power() { false, ), veto: None, + delegation_module: None, }; let core_addr = instantiate_with_cw20_balances_governance( @@ -1953,6 +1974,7 @@ fn test_cant_execute_not_member() { voting_strategy, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( @@ -2045,6 +2067,7 @@ fn test_cant_execute_not_member_when_proposal_created() { voting_strategy, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( @@ -2164,6 +2187,7 @@ fn test_open_proposal_submission() { close_proposal_on_execution_failure: true, pre_propose_info: get_pre_propose_info(&mut app, None, true), veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); let govmod = query_multiple_proposal_module(&app, &core_addr); @@ -2234,6 +2258,7 @@ fn test_open_proposal_submission() { vote_weights: vec![Uint128::zero(); 3], }, veto: None, + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -2466,6 +2491,7 @@ fn test_execute_expired_proposal() { voting_strategy, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( @@ -2765,6 +2791,7 @@ fn test_query_list_proposals() { voting_strategy: voting_strategy.clone(), pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let gov_addr = instantiate_with_staked_balances_governance( &mut app, @@ -2847,6 +2874,7 @@ fn test_query_list_proposals() { allow_revoting: false, min_voting_period: None, veto: None, + delegation_module: None, }, }; assert_eq!(proposals_forward.proposals[0], expected); @@ -2876,6 +2904,7 @@ fn test_query_list_proposals() { allow_revoting: false, min_voting_period: None, veto: None, + delegation_module: None, }, }; assert_eq!(proposals_forward.proposals[0], expected); @@ -2902,6 +2931,7 @@ fn test_hooks() { voting_strategy, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance(&mut app, instantiate, None); @@ -3029,6 +3059,7 @@ fn test_active_threshold_absolute() { voting_strategy, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staking_active_threshold( @@ -3160,6 +3191,7 @@ fn test_active_threshold_percent() { voting_strategy, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; // 20% needed to be active, 20% of 100000000 is 20000000 @@ -3292,6 +3324,7 @@ fn test_active_threshold_none() { voting_strategy, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = @@ -3405,6 +3438,7 @@ fn test_revoting() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -3539,6 +3573,7 @@ fn test_allow_revoting_config_changes() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -3694,6 +3729,7 @@ fn test_revoting_same_vote_twice() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -3790,6 +3826,7 @@ fn test_invalid_revote_does_not_invalidate_initial_vote() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -3985,6 +4022,7 @@ fn test_close_failed_proposal() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staking_active_threshold(&mut app, instantiate, None, None); @@ -4240,6 +4278,7 @@ fn test_no_double_refund_on_execute_fail_and_close() { false, ), veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staking_active_threshold( @@ -4420,6 +4459,7 @@ pub fn test_not_allow_voting_on_expired_proposal() { close_proposal_on_execution_failure: true, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( &mut app, @@ -4513,6 +4553,7 @@ fn test_next_proposal_id() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -4586,6 +4627,7 @@ fn test_vote_with_rationale() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -4684,6 +4726,7 @@ fn test_revote_with_rationale() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -4840,6 +4883,7 @@ fn test_update_rationale() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -4979,6 +5023,7 @@ fn test_open_proposal_passes_with_zero_timelock_veto_duration() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5088,6 +5133,7 @@ fn test_veto_non_existing_prop_id() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5134,6 +5180,7 @@ fn test_veto_with_no_veto_configuration() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5226,6 +5273,7 @@ fn test_veto_open_prop_with_veto_before_passed_disabled() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5334,6 +5382,7 @@ fn test_veto_when_veto_timelock_expired() -> anyhow::Result<()> { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5449,6 +5498,7 @@ fn test_veto_sets_prop_status_to_vetoed() -> anyhow::Result<()> { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5560,6 +5610,7 @@ fn test_veto_from_catchall_state() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5681,6 +5732,7 @@ fn test_veto_timelock_early_execute_happy() -> anyhow::Result<()> { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5805,6 +5857,7 @@ fn test_veto_timelock_expires_happy() -> anyhow::Result<()> { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -5918,6 +5971,7 @@ fn test_veto_only_members_execute_proposal() -> anyhow::Result<()> { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: Some(veto_config), + delegation_module: None, }, Some(vec![ Cw20Coin { diff --git a/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json b/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json index c08edfbc4..89ddf2909 100644 --- a/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json +++ b/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json @@ -23,6 +23,13 @@ "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.", "type": "boolean" }, + "delegation_module": { + "description": "The address of the delegation module to use for this proposal module (if any).", + "type": [ + "string", + "null" + ] + }, "max_voting_period": { "description": "The default maximum amount of time a proposal may be voted on before expiring.", "allOf": [ @@ -671,6 +678,28 @@ }, "additionalProperties": false }, + { + "description": "Update's the address of the delegation module associated with this proposal module. Only the DAO may call this method.", + "type": "object", + "required": [ + "update_delegation_module" + ], + "properties": { + "update_delegation_module": { + "type": "object", + "required": [ + "module" + ], + "properties": { + "module": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Adds an address as a consumer of proposal hooks. Consumers of proposal hooks have hook messages executed on them whenever the status of a proposal changes or a proposal is created. If a consumer contract errors when handling a hook message it will be removed from the list of consumers.", "type": "object", @@ -2186,6 +2215,20 @@ }, "additionalProperties": false }, + { + "description": "Gets the address of the delegation module associated with this proposal module (if any).", + "type": "object", + "required": [ + "delegation_module" + ], + "properties": { + "delegation_module": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Lists all of the consumers of proposal hooks for this module.", "type": "object", @@ -2810,6 +2853,24 @@ "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", "type": "string" }, + "delegation_module": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Nullable_Addr", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } + }, "get_vote": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "VoteResponse", @@ -3583,6 +3644,17 @@ "description": "Whether or not revoting is enabled. If revoting is enabled, a proposal cannot pass until the voting period has elapsed.", "type": "boolean" }, + "delegation_module": { + "description": "The address of the delegation module associated with this proposal (if one existed when the proposal was created).", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "description": { "description": "The main body of the proposal text", "type": "string" @@ -4962,6 +5034,17 @@ "description": "Whether or not revoting is enabled. If revoting is enabled, a proposal cannot pass until the voting period has elapsed.", "type": "boolean" }, + "delegation_module": { + "description": "The address of the delegation module associated with this proposal (if one existed when the proposal was created).", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "description": { "description": "The main body of the proposal text", "type": "string" @@ -6327,6 +6410,17 @@ "description": "Whether or not revoting is enabled. If revoting is enabled, a proposal cannot pass until the voting period has elapsed.", "type": "boolean" }, + "delegation_module": { + "description": "The address of the delegation module associated with this proposal (if one existed when the proposal was created).", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, "description": { "description": "The main body of the proposal text", "type": "string" diff --git a/contracts/proposal/dao-proposal-single/src/contract.rs b/contracts/proposal/dao-proposal-single/src/contract.rs index ebb8e6414..69563fa96 100644 --- a/contracts/proposal/dao-proposal-single/src/contract.rs +++ b/contracts/proposal/dao-proposal-single/src/contract.rs @@ -13,6 +13,7 @@ use dao_hooks::proposal::{ }; use dao_hooks::vote::new_vote_hooks; use dao_interface::voting::IsActiveResponse; +use dao_voting::delegation::{self, calculate_delegated_vp, Delegation}; use dao_voting::pre_propose::{PreProposeInfo, ProposalCreationPolicy}; use dao_voting::proposal::{ SingleChoiceProposeMsg as ProposeMsg, DEFAULT_LIMIT, MAX_PROPOSAL_SIZE, @@ -23,11 +24,14 @@ use dao_voting::reply::{ use dao_voting::status::Status; use dao_voting::threshold::Threshold; use dao_voting::veto::{VetoConfig, VetoError}; -use dao_voting::voting::{get_total_power, get_voting_power, validate_voting_period, Vote, Votes}; +use dao_voting::voting::{ + get_total_power, get_voting_power, get_voting_power_with_delegation, validate_voting_period, + Vote, Votes, +}; use crate::msg::MigrateMsg; use crate::proposal::{next_proposal_id, SingleChoiceProposal}; -use crate::state::{Config, CREATION_POLICY}; +use crate::state::{Config, CREATION_POLICY, DELEGATION_MODULE}; use crate::v1_state::{ v1_duration_to_v2, v1_expiration_to_v2, v1_status_to_v2, v1_threshold_to_v2, v1_votes_to_v2, }; @@ -135,6 +139,9 @@ pub fn execute( ExecuteMsg::UpdatePreProposeInfo { info: new_info } => { execute_update_proposal_creation_policy(deps, info, new_info) } + ExecuteMsg::UpdateDelegationModule { module } => { + execute_update_delegation_module(deps, info, module) + } ExecuteMsg::AddProposalHook { address } => { execute_add_proposal_hook(deps, env, info, address) } @@ -198,6 +205,9 @@ pub fn execute_propose( return Err(ContractError::InactiveDao {}); } + // Delegation module may or may not exist. + let delegation_module = DELEGATION_MODULE.may_load(deps.storage)?; + let expiration = config.max_voting_period.after(&env.block); let total_power = get_total_power(deps.as_ref(), &config.dao, Some(env.block.height))?; @@ -218,6 +228,7 @@ pub fn execute_propose( votes: Votes::zero(), allow_revoting: config.allow_revoting, veto: config.veto, + delegation_module, }; // Update the proposal's status. Addresses case where proposal // expires on the same block as it is created. @@ -493,11 +504,14 @@ pub fn execute_vote( return Err(ContractError::Expired { id: proposal_id }); } - let vote_power = get_voting_power( + let vote_power = get_voting_power_with_delegation( deps.as_ref(), - sender.clone(), + &env.contract.address, + &prop.delegation_module, &config.dao, - Some(prop.start_height), + &sender, + proposal_id, + prop.start_height, )?; if vote_power.is_zero() { return Err(ContractError::NotRegistered {}); @@ -541,6 +555,50 @@ pub fn execute_vote( }), })?; + // DELEGATION VOTE OVERRIDE: if this is the first time this member voted, + // subtract their VP from the vote tally of all of their delegates who + // already voted on this proposal, in order to override their vote with the + // delegator's preference. + // + // we must load all delegations and update each. if this partially fails, + // the vote tallies will be incorrect, so the entire vote transaction should + // fail. we need to prevent this from happening by limiting the number of + // delegations a member can have in order to ensure votes can always be + // cast. + if is_first_vote { + if let Some(delegation_module) = &prop.delegation_module { + let delegations = deps + .querier + .query_wasm_smart::( + delegation_module, + &delegation::QueryMsg::Delegations { + delegator: sender.to_string(), + height: Some(prop.start_height), + offset: None, + limit: None, + }, + // ensure query error gets returned if it fails. + )? + .delegations; + + for Delegation { delegate, percent } in delegations { + // if delegate voted already, untally the VP the delegator + // delegated to them since the delegate's vote is being + // overridden. + if let Some(mut delegate_ballot) = + BALLOTS.may_load(deps.storage, (proposal_id, &delegate))? + { + let delegated_vp = calculate_delegated_vp(vote_power, percent); + + prop.votes.remove_vote(delegate_ballot.vote, delegated_vp); + + delegate_ballot.power = delegate_ballot.power.checked_sub(delegated_vp)?; + BALLOTS.save(deps.storage, (proposal_id, &delegate), &delegate_ballot)?; + } + } + } + } + let old_status = prop.status; prop.votes.add_vote(vote, vote_power); @@ -723,6 +781,23 @@ pub fn execute_update_proposal_creation_policy( .add_attribute("new_policy", format!("{initial_policy:?}"))) } +pub fn execute_update_delegation_module( + deps: DepsMut, + info: MessageInfo, + module: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + if config.dao != info.sender { + return Err(ContractError::Unauthorized {}); + } + + DELEGATION_MODULE.save(deps.storage, &deps.api.addr_validate(&module)?)?; + + Ok(Response::default() + .add_attribute("action", "update_delegation_module") + .add_attribute("module", module)) +} + pub fn add_hook( hooks: Hooks, storage: &mut dyn Storage, @@ -852,6 +927,7 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { limit, } => query_reverse_proposals(deps, env, start_before, limit), QueryMsg::ProposalCreationPolicy {} => query_creation_policy(deps), + QueryMsg::DelegationModule {} => query_delegation_module(deps), QueryMsg::ProposalHooks {} => to_json_binary(&PROPOSAL_HOOKS.query_hooks(deps)?), QueryMsg::VoteHooks {} => to_json_binary(&VOTE_HOOKS.query_hooks(deps)?), } @@ -877,6 +953,11 @@ pub fn query_creation_policy(deps: Deps) -> StdResult { to_json_binary(&policy) } +pub fn query_delegation_module(deps: Deps) -> StdResult { + let module = DELEGATION_MODULE.may_load(deps.storage)?; + to_json_binary(&module) +} + pub fn query_list_proposals( deps: Deps, env: Env, @@ -1056,6 +1137,7 @@ pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result, + /// The address of the delegation module to use for this proposal module (if + /// any). + pub delegation_module: Option, } #[cw_serde] @@ -128,6 +131,9 @@ pub enum ExecuteMsg { /// Update's the proposal creation policy used for this /// module. Only the DAO may call this method. UpdatePreProposeInfo { info: PreProposeInfo }, + /// Update's the address of the delegation module associated with this + /// proposal module. Only the DAO may call this method. + UpdateDelegationModule { module: String }, /// Adds an address as a consumer of proposal hooks. Consumers of /// proposal hooks have hook messages executed on them whenever /// the status of a proposal changes or a proposal is created. If @@ -203,6 +209,10 @@ pub enum QueryMsg { /// Gets the current proposal creation policy for this module. #[returns(::dao_voting::pre_propose::ProposalCreationPolicy)] ProposalCreationPolicy {}, + /// Gets the address of the delegation module associated with this + /// proposal module (if any). + #[returns(Option<::cosmwasm_std::Addr>)] + DelegationModule {}, /// Lists all of the consumers of proposal hooks for this module. #[returns(::cw_hooks::HooksResponse)] ProposalHooks {}, diff --git a/contracts/proposal/dao-proposal-single/src/proposal.rs b/contracts/proposal/dao-proposal-single/src/proposal.rs index a597f3754..290921410 100644 --- a/contracts/proposal/dao-proposal-single/src/proposal.rs +++ b/contracts/proposal/dao-proposal-single/src/proposal.rs @@ -46,6 +46,9 @@ pub struct SingleChoiceProposal { /// Optional veto configuration. If set to `None`, veto option /// is disabled. Otherwise contains the configuration for veto flow. pub veto: Option, + /// The address of the delegation module associated with this proposal (if + /// one existed when the proposal was created). + pub delegation_module: Option, } pub fn next_proposal_id(store: &dyn Storage) -> StdResult { @@ -312,6 +315,7 @@ mod test { veto: None, total_power, votes, + delegation_module: None, }; (prop, block) } diff --git a/contracts/proposal/dao-proposal-single/src/state.rs b/contracts/proposal/dao-proposal-single/src/state.rs index 88e748b51..1391ca757 100644 --- a/contracts/proposal/dao-proposal-single/src/state.rs +++ b/contracts/proposal/dao-proposal-single/src/state.rs @@ -77,3 +77,6 @@ pub const VOTE_HOOKS: Hooks = Hooks::new("vote_hooks"); /// The address of the pre-propose module associated with this /// proposal module (if any). pub const CREATION_POLICY: Item = Item::new("creation_policy"); +/// The address of the delegation module associated with this proposal module +/// (if any). +pub const DELEGATION_MODULE: Item = Item::new("delegation_module"); diff --git a/contracts/proposal/dao-proposal-single/src/testing/adversarial_tests.rs b/contracts/proposal/dao-proposal-single/src/testing/adversarial_tests.rs index 40b5fcd1c..6fb8cd9e2 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/adversarial_tests.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/adversarial_tests.rs @@ -181,6 +181,7 @@ pub fn test_executed_prop_state_remains_after_vote_swing() { false, ), close_proposal_on_execution_failure: true, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( @@ -280,6 +281,7 @@ pub fn test_passed_prop_state_remains_after_vote_swing() { false, ), close_proposal_on_execution_failure: true, + delegation_module: None, }; let core_addr = instantiate_with_staked_balances_governance( diff --git a/contracts/proposal/dao-proposal-single/src/testing/do_votes.rs b/contracts/proposal/dao-proposal-single/src/testing/do_votes.rs index 3646a9ecf..4666bf777 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/do_votes.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/do_votes.rs @@ -142,6 +142,7 @@ where allow_revoting: false, close_proposal_on_execution_failure: true, pre_propose_info, + delegation_module: None, }; let core_addr = setup_governance(&mut app, instantiate, Some(initial_balances)); diff --git a/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs b/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs index 4f469476f..74b50330a 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs @@ -79,6 +79,7 @@ pub(crate) fn get_default_token_dao_proposal_module_instantiate(app: &mut App) - false, ), close_proposal_on_execution_failure: true, + delegation_module: None, } } @@ -98,6 +99,7 @@ pub(crate) fn get_default_non_token_dao_proposal_module_instantiate( allow_revoting: false, pre_propose_info: get_pre_propose_info(app, None, false), close_proposal_on_execution_failure: true, + delegation_module: None, } } diff --git a/contracts/proposal/dao-proposal-single/src/testing/tests.rs b/contracts/proposal/dao-proposal-single/src/testing/tests.rs index 242b84cd7..777cf66ab 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/tests.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/tests.rs @@ -133,6 +133,7 @@ fn test_simple_propose_staked_balances() { status: Status::Open, veto: None, votes: Votes::zero(), + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -183,6 +184,7 @@ fn test_simple_proposal_cw4_voting() { status: Status::Open, veto: None, votes: Votes::zero(), + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -239,6 +241,7 @@ fn test_simple_proposal_auto_vote_yes() { no: Uint128::zero(), abstain: Uint128::zero(), }, + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -288,6 +291,7 @@ fn test_simple_proposal_auto_vote_no() { no: Uint128::new(1), abstain: Uint128::zero(), }, + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -386,6 +390,7 @@ fn test_instantiate_with_non_voting_module_cw20_deposit() { status: Status::Open, votes: Votes::zero(), veto: None, + delegation_module: None, }; assert_eq!(created.proposal, expected); @@ -2246,7 +2251,8 @@ fn test_anyone_may_propose_and_proposal_listing() { no: Uint128::zero(), abstain: Uint128::zero() }, - veto: None + veto: None, + delegation_module: None, } } ) @@ -3055,6 +3061,7 @@ fn test_proposal_count_initialized_to_zero() { allow_revoting: false, pre_propose_info, close_proposal_on_execution_failure: true, + delegation_module: None, }, Some(vec![ Cw20Coin { @@ -3529,6 +3536,7 @@ fn test_reply_proposal_mock() { status: Status::Open, veto: None, votes: Votes::zero(), + delegation_module: None, }, ) .unwrap(); diff --git a/contracts/test/dao-proposal-hook-counter/src/tests.rs b/contracts/test/dao-proposal-hook-counter/src/tests.rs index e28ed1b34..6255e6f97 100644 --- a/contracts/test/dao-proposal-hook-counter/src/tests.rs +++ b/contracts/test/dao-proposal-hook-counter/src/tests.rs @@ -111,6 +111,7 @@ fn test_counters() { pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, }; let governance_addr = diff --git a/packages/dao-interface/src/helpers.rs b/packages/dao-interface/src/helpers.rs new file mode 100644 index 000000000..40ba76c2c --- /dev/null +++ b/packages/dao-interface/src/helpers.rs @@ -0,0 +1,35 @@ +use cosmwasm_schema::cw_serde; + +#[cw_serde] +pub enum Update { + Set(T), + Clear, +} + +/// An update type that allows partial updates of optional fields. +#[cw_serde] +pub struct OptionalUpdate(Option>); + +impl OptionalUpdate { + /// Updates the value if it exists, otherwise does nothing. + pub fn maybe_update(self, update: impl FnOnce(Option)) { + match self.0 { + Some(Update::Set(value)) => update(Some(value)), + Some(Update::Clear) => update(None), + None => (), + } + } + + /// Updates the value if it exists, otherwise does nothing, requiring the + /// update action to return a result. + pub fn maybe_update_result( + self, + update: impl FnOnce(Option) -> Result<(), E>, + ) -> Result<(), E> { + match self.0 { + Some(Update::Set(value)) => update(Some(value)), + Some(Update::Clear) => update(None), + None => Ok(()), + } + } +} diff --git a/packages/dao-interface/src/lib.rs b/packages/dao-interface/src/lib.rs index aae0678fd..ac97e1c6a 100644 --- a/packages/dao-interface/src/lib.rs +++ b/packages/dao-interface/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] +pub mod helpers; pub mod migrate_msg; pub mod msg; pub mod nft; diff --git a/packages/dao-voting/src/delegation.rs b/packages/dao-voting/src/delegation.rs new file mode 100644 index 000000000..f10a1c2ee --- /dev/null +++ b/packages/dao-voting/src/delegation.rs @@ -0,0 +1,92 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Decimal, Uint128}; +use dao_interface::voting::InfoResponse; + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns contract version info + #[returns(InfoResponse)] + Info {}, + /// Returns the paginated list of active delegates. + #[returns(DelegatesResponse)] + Delegates { + start_after: Option, + limit: Option, + }, + /// Returns the delegations by a delegator, optionally at a given height. + /// Uses the current block height if not provided. + #[returns(DelegationsResponse)] + Delegations { + delegator: String, + height: Option, + offset: Option, + limit: Option, + }, + /// Returns the VP delegated to a delegate that has not yet been used in + /// votes cast by delegators in a specific proposal. + #[returns(Uint128)] + UnvotedDelegatedVotingPower { + delegate: String, + proposal_module: String, + proposal_id: u64, + height: u64, + }, + /// Returns the proposal modules synced from the DAO. + #[returns(Vec)] + ProposalModules { + start_after: Option, + limit: Option, + }, + /// Returns the voting power hook callers. + #[returns(Vec)] + VotingPowerHookCallers { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct DelegatesResponse { + /// The delegates. + pub delegates: Vec, +} + +#[cw_serde] +pub struct DelegateResponse { + /// The delegate. + pub delegate: Addr, + /// The total voting power delegated to the delegate. + pub power: Uint128, +} + +#[cw_serde] +#[derive(Default)] +pub struct DelegationsResponse { + /// The delegations. + pub delegations: Vec, + /// The height at which the delegations were loaded. + pub height: u64, +} + +#[cw_serde] +pub struct Delegate {} + +#[cw_serde] +pub struct Delegation { + /// the delegate that can vote on behalf of the delegator. + pub delegate: Addr, + /// the percent of the delegator's voting power that is delegated to the + /// delegate. + pub percent: Decimal, +} + +/// Calculate delegated voting power given a member's total voting power and a +/// percent delegated. +pub fn calculate_delegated_vp(vp: Uint128, percent: Decimal) -> Uint128 { + if percent.is_zero() || vp.is_zero() { + return Uint128::zero(); + } + + vp.mul_floor(percent) +} diff --git a/packages/dao-voting/src/lib.rs b/packages/dao-voting/src/lib.rs index ad2ff0d3a..c064504ae 100644 --- a/packages/dao-voting/src/lib.rs +++ b/packages/dao-voting/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] pub mod approval; +pub mod delegation; pub mod deposit; pub mod duration; pub mod error; diff --git a/packages/dao-voting/src/voting.rs b/packages/dao-voting/src/voting.rs index 2aabafc2e..6c8fd5e96 100644 --- a/packages/dao-voting/src/voting.rs +++ b/packages/dao-voting/src/voting.rs @@ -3,7 +3,7 @@ use cosmwasm_std::{Addr, Decimal, Deps, StdResult, Uint128, Uint256}; use cw_utils::Duration; use dao_interface::voting; -use crate::threshold::PercentageThreshold; +use crate::{delegation, threshold::PercentageThreshold}; // We multiply by this when calculating needed_votes in order to round // up properly. @@ -216,6 +216,55 @@ pub fn get_voting_power( Ok(response.power) } +/// Query the voting power for a member, including any voting power delegated to +/// them by other members, given the proposal and its start_height. This should +/// be used when calculating voting power used to vote. This is not necessary +/// when simply member-gating proposal creation, execution, nor closure, since +/// delegates must be members of the DAO anyway. +pub fn get_voting_power_with_delegation( + deps: Deps, + proposal_module: &Addr, + delegation_module: &Option, + dao: &Addr, + address: &Addr, + proposal_id: u64, + // the proposal start_height at which voting power should be queried + height: u64, +) -> StdResult { + // get individual voting power from voting module + let voting::VotingPowerAtHeightResponse { power, .. } = deps.querier.query_wasm_smart( + dao, + &voting::Query::VotingPowerAtHeight { + address: address.to_string(), + height: Some(height), + }, + )?; + + // get voting power delegated to this address from other members of the DAO + // that has not yet been used to vote on the given proposal. if this query + // fails, fail gracefully and assume 0 delegated VP to ensure votes can + // still be cast. + let udvp = delegation_module + .as_ref() + .map(|dm| -> StdResult { + deps.querier.query_wasm_smart( + dm, + &delegation::QueryMsg::UnvotedDelegatedVotingPower { + delegate: address.to_string(), + proposal_module: proposal_module.to_string(), + proposal_id, + height, + }, + ) + }) + .unwrap_or_else(|| Ok(Uint128::zero())) + // fail gracefully if the query fails + .unwrap_or_default(); + + // sum both to get total voting power for this address on this proposal + Ok(power.checked_add(udvp)?) +} + /// A height of None will query for the current block height. pub fn get_total_power(deps: Deps, dao: &Addr, height: Option) -> StdResult { let response: voting::TotalPowerAtHeightResponse = deps diff --git a/scripts/schema.sh b/scripts/schema.sh index 1dec7ff02..c459be8e9 100755 --- a/scripts/schema.sh +++ b/scripts/schema.sh @@ -13,6 +13,16 @@ cargo run --example schema > /dev/null rm -rf ./schema/raw cd "$START_DIR" +for f in ./contracts/delegation/* +do + echo "generating schema for ${f##*/}" + cd "$f" + CMD="cargo run --example schema" + eval $CMD > /dev/null + rm -rf ./schema/raw + cd "$START_DIR" +done + for f in ./contracts/distribution/* do echo "generating schema for ${f##*/}" From ad5c1019d411d60f4535bf7c2903cda0df918019 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 14 Oct 2024 14:55:54 -0400 Subject: [PATCH 10/24] added delegated VP cap --- .../schema/dao-vote-delegation.json | 36 +++++++++-- .../dao-vote-delegation/src/contract.rs | 27 ++++++-- .../dao-proposal-multiple/src/contract.rs | 64 +++++++++++++++++-- .../dao-proposal-single/src/contract.rs | 64 +++++++++++++++++-- .../proposal/dao-proposal-single/src/state.rs | 3 +- packages/dao-voting/src/delegation.rs | 18 +++++- packages/dao-voting/src/voting.rs | 14 ++-- 7 files changed, 196 insertions(+), 30 deletions(-) diff --git a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json index c81e235bd..35bbb440a 100644 --- a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json +++ b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json @@ -647,7 +647,7 @@ "additionalProperties": false }, { - "description": "Returns the VP delegated to a delegate that has not yet been used in votes cast by delegators in a specific proposal.", + "description": "Returns the VP delegated to a delegate that has not yet been used in votes cast by delegators in a specific proposal. This updates immediately via vote hooks (instead of being delayed 1 block like other historical queries), making it safe to vote multiple times in the same block. Proposal modules are responsible for maintaining the effective VP cap when a delegator overrides a delegate's vote.", "type": "object", "required": [ "unvoted_delegated_voting_power" @@ -919,9 +919,37 @@ }, "unvoted_delegated_voting_power": { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Uint128", - "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", - "type": "string" + "title": "UnvotedDelegatedVotingPowerResponse", + "type": "object", + "required": [ + "effective", + "total" + ], + "properties": { + "effective": { + "description": "The unvoted delegated voting power in effect, with configured constraints applied, such as the VP cap.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "total": { + "description": "The total unvoted delegated voting power.", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } }, "voting_power_hook_callers": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 7083ca499..5e779882b 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -11,7 +11,8 @@ use cw_utils::{maybe_addr, nonpayable}; use dao_interface::helpers::OptionalUpdate; use dao_interface::state::{ProposalModule, ProposalModuleStatus}; use dao_interface::voting::InfoResponse; -use dao_voting::delegation::calculate_delegated_vp; +use dao_voting::delegation::{calculate_delegated_vp, UnvotedDelegatedVotingPowerResponse}; +use dao_voting::voting; use semver::Version; use crate::helpers::{ @@ -540,17 +541,35 @@ fn query_unvoted_delegated_vp( proposal_module: String, proposal_id: u64, height: u64, -) -> StdResult { +) -> StdResult { let delegate = deps.api.addr_validate(&delegate)?; // if delegate not registered, they have no unvoted delegated VP. if !is_delegate_registered(deps, &delegate, Some(height))? { - return Ok(Uint128::zero()); + return Ok(UnvotedDelegatedVotingPowerResponse { + total: Uint128::zero(), + effective: Uint128::zero(), + }); } let proposal_module = deps.api.addr_validate(&proposal_module)?; - get_udvp(deps, &delegate, &proposal_module, proposal_id, height) + let total = get_udvp(deps, &delegate, &proposal_module, proposal_id, height)?; + let mut effective = total; + + // if a VP cap is set, apply it to the total VP to get the effective VP. + let config = CONFIG.load(deps.storage)?; + if let Some(vp_cap_percent) = config.vp_cap_percent { + if vp_cap_percent < Decimal::one() { + let dao = DAO.load(deps.storage)?; + let total_power = voting::get_total_power(deps, &dao, Some(height))?; + let cap = calculate_delegated_vp(total_power, vp_cap_percent); + + effective = total.min(cap); + } + } + + Ok(UnvotedDelegatedVotingPowerResponse { total, effective }) } fn query_proposal_modules( diff --git a/contracts/proposal/dao-proposal-multiple/src/contract.rs b/contracts/proposal/dao-proposal-multiple/src/contract.rs index 4d99a9ea8..98749bb04 100644 --- a/contracts/proposal/dao-proposal-multiple/src/contract.rs +++ b/contracts/proposal/dao-proposal-multiple/src/contract.rs @@ -14,7 +14,9 @@ use dao_hooks::proposal::{ }; use dao_hooks::vote::new_vote_hooks; use dao_interface::voting::IsActiveResponse; -use dao_voting::delegation::{self, calculate_delegated_vp, Delegation}; +use dao_voting::delegation::{ + self, calculate_delegated_vp, Delegation, UnvotedDelegatedVotingPowerResponse, +}; use dao_voting::voting::get_voting_power_with_delegation; use dao_voting::{ multiple_choice::{MultipleChoiceVote, MultipleChoiceVotes, VotingStrategy}, @@ -483,12 +485,60 @@ pub fn execute_vote( if let Some(mut delegate_ballot) = BALLOTS.may_load(deps.storage, (proposal_id, &delegate))? { - let delegated_vp = calculate_delegated_vp(vote_power, percent); - - prop.votes.remove_vote(delegate_ballot.vote, delegated_vp)?; - - delegate_ballot.power = delegate_ballot.power.checked_sub(delegated_vp)?; - BALLOTS.save(deps.storage, (proposal_id, &delegate), &delegate_ballot)?; + // get the delegate's current unvoted delegated VP. since we + // are currently overriding this delegate's vote, this UDVP + // response will not yet take into account the loss of this + // current voter's delegated VP, so we have to do math below + // to remove this voter's VP from the delegate's effective + // VP. the vote hook at the end of this fn will update this + // UDVP in the delegation module for future votes. + // + // NOTE: this UDVP query reflects updates immediately, + // instead of waiting 1 block to take effect like other + // historical queries, so this will reflect the updated UDVP + // from the vote hooks within the same block, making it safe + // to vote twice in the same block. + let prev_udvp: UnvotedDelegatedVotingPowerResponse = + deps.querier.query_wasm_smart( + delegation_module, + &delegation::QueryMsg::UnvotedDelegatedVotingPower { + delegate: delegate.to_string(), + proposal_module: env.contract.address.to_string(), + proposal_id, + height: prop.start_height, + }, + )?; + + let voter_delegated_vp = calculate_delegated_vp(vote_power, percent); + + // subtract this voter's delegated VP from the delegate's + // total VP, and cap the result at the delegate's effective + // VP. if the delegate has been delegated in total more than + // this voter's delegated VP above the cap, they will not + // lose any VP. they will lose part or all of this voter's + // delegated VP based on how their total VP ranks relative + // to the cap. + let new_effective_delegated = prev_udvp + .total + .checked_sub(voter_delegated_vp)? + .min(prev_udvp.effective); + + // if the new effective VP is less than the previous + // effective VP, update the delegate's ballot and tally. + if new_effective_delegated < prev_udvp.effective { + // how much VP the delegate is losing based on this + // voter's VP and the cap. + let diff = prev_udvp.effective.checked_sub(new_effective_delegated)?; + + // update ballot total and vote tally by removing the + // lost delegated VP only. this makes sure to fully + // preserve the delegate's personal VP even if they lose + // all delegated VP due to delegators overriding votes. + delegate_ballot.power -= diff; + prop.votes.remove_vote(delegate_ballot.vote, diff)?; + + BALLOTS.save(deps.storage, (proposal_id, &delegate), &delegate_ballot)?; + } } } } diff --git a/contracts/proposal/dao-proposal-single/src/contract.rs b/contracts/proposal/dao-proposal-single/src/contract.rs index 69563fa96..d343e5488 100644 --- a/contracts/proposal/dao-proposal-single/src/contract.rs +++ b/contracts/proposal/dao-proposal-single/src/contract.rs @@ -13,7 +13,9 @@ use dao_hooks::proposal::{ }; use dao_hooks::vote::new_vote_hooks; use dao_interface::voting::IsActiveResponse; -use dao_voting::delegation::{self, calculate_delegated_vp, Delegation}; +use dao_voting::delegation::{ + self, calculate_delegated_vp, Delegation, UnvotedDelegatedVotingPowerResponse, +}; use dao_voting::pre_propose::{PreProposeInfo, ProposalCreationPolicy}; use dao_voting::proposal::{ SingleChoiceProposeMsg as ProposeMsg, DEFAULT_LIMIT, MAX_PROPOSAL_SIZE, @@ -588,12 +590,60 @@ pub fn execute_vote( if let Some(mut delegate_ballot) = BALLOTS.may_load(deps.storage, (proposal_id, &delegate))? { - let delegated_vp = calculate_delegated_vp(vote_power, percent); - - prop.votes.remove_vote(delegate_ballot.vote, delegated_vp); - - delegate_ballot.power = delegate_ballot.power.checked_sub(delegated_vp)?; - BALLOTS.save(deps.storage, (proposal_id, &delegate), &delegate_ballot)?; + // get the delegate's current unvoted delegated VP. since we + // are currently overriding this delegate's vote, this UDVP + // response will not yet take into account the loss of this + // current voter's delegated VP, so we have to do math below + // to remove this voter's VP from the delegate's effective + // VP. the vote hook at the end of this fn will update this + // UDVP in the delegation module for future votes. + // + // NOTE: this UDVP query reflects updates immediately, + // instead of waiting 1 block to take effect like other + // historical queries, so this will reflect the updated UDVP + // from the vote hooks within the same block, making it safe + // to vote twice in the same block. + let prev_udvp: UnvotedDelegatedVotingPowerResponse = + deps.querier.query_wasm_smart( + delegation_module, + &delegation::QueryMsg::UnvotedDelegatedVotingPower { + delegate: delegate.to_string(), + proposal_module: env.contract.address.to_string(), + proposal_id, + height: prop.start_height, + }, + )?; + + let voter_delegated_vp = calculate_delegated_vp(vote_power, percent); + + // subtract this voter's delegated VP from the delegate's + // total VP, and cap the result at the delegate's effective + // VP. if the delegate has been delegated in total more than + // this voter's delegated VP above the cap, they will not + // lose any VP. they will lose part or all of this voter's + // delegated VP based on how their total VP ranks relative + // to the cap. + let new_effective_delegated = prev_udvp + .total + .checked_sub(voter_delegated_vp)? + .min(prev_udvp.effective); + + // if the new effective VP is less than the previous + // effective VP, update the delegate's ballot and tally. + if new_effective_delegated < prev_udvp.effective { + // how much VP the delegate is losing based on this + // voter's VP and the cap. + let diff = prev_udvp.effective.checked_sub(new_effective_delegated)?; + + // update ballot total and vote tally by removing the + // lost delegated VP only. this makes sure to fully + // preserve the delegate's personal VP even if they lose + // all delegated VP due to delegators overriding votes. + delegate_ballot.power -= diff; + prop.votes.remove_vote(delegate_ballot.vote, diff); + + BALLOTS.save(deps.storage, (proposal_id, &delegate), &delegate_ballot)?; + } } } } diff --git a/contracts/proposal/dao-proposal-single/src/state.rs b/contracts/proposal/dao-proposal-single/src/state.rs index 1391ca757..f02fdca6b 100644 --- a/contracts/proposal/dao-proposal-single/src/state.rs +++ b/contracts/proposal/dao-proposal-single/src/state.rs @@ -12,7 +12,8 @@ use crate::proposal::SingleChoiceProposal; /// A vote cast for a proposal. #[cw_serde] pub struct Ballot { - /// The amount of voting power behind the vote. + /// The amount of voting power behind the vote, including any delegated VP. + /// This is the amount tallied in the proposal for this ballot. pub power: Uint128, /// The position. pub vote: Vote, diff --git a/packages/dao-voting/src/delegation.rs b/packages/dao-voting/src/delegation.rs index f10a1c2ee..7fee0d755 100644 --- a/packages/dao-voting/src/delegation.rs +++ b/packages/dao-voting/src/delegation.rs @@ -24,8 +24,12 @@ pub enum QueryMsg { limit: Option, }, /// Returns the VP delegated to a delegate that has not yet been used in - /// votes cast by delegators in a specific proposal. - #[returns(Uint128)] + /// votes cast by delegators in a specific proposal. This updates + /// immediately via vote hooks (instead of being delayed 1 block like other + /// historical queries), making it safe to vote multiple times in the same + /// block. Proposal modules are responsible for maintaining the effective VP + /// cap when a delegator overrides a delegate's vote. + #[returns(UnvotedDelegatedVotingPowerResponse)] UnvotedDelegatedVotingPower { delegate: String, proposal_module: String, @@ -69,6 +73,16 @@ pub struct DelegationsResponse { pub height: u64, } +#[cw_serde] +#[derive(Default)] +pub struct UnvotedDelegatedVotingPowerResponse { + /// The total unvoted delegated voting power. + pub total: Uint128, + /// The unvoted delegated voting power in effect, with configured + /// constraints applied, such as the VP cap. + pub effective: Uint128, +} + #[cw_serde] pub struct Delegate {} diff --git a/packages/dao-voting/src/voting.rs b/packages/dao-voting/src/voting.rs index 6c8fd5e96..5cbca1eed 100644 --- a/packages/dao-voting/src/voting.rs +++ b/packages/dao-voting/src/voting.rs @@ -3,7 +3,10 @@ use cosmwasm_std::{Addr, Decimal, Deps, StdResult, Uint128, Uint256}; use cw_utils::Duration; use dao_interface::voting; -use crate::{delegation, threshold::PercentageThreshold}; +use crate::{ + delegation::{self, UnvotedDelegatedVotingPowerResponse}, + threshold::PercentageThreshold, +}; // We multiply by this when calculating needed_votes in order to round // up properly. @@ -240,13 +243,13 @@ pub fn get_voting_power_with_delegation( }, )?; - // get voting power delegated to this address from other members of the DAO + // get effective VP delegated to this address from other members of the DAO // that has not yet been used to vote on the given proposal. if this query // fails, fail gracefully and assume 0 delegated VP to ensure votes can // still be cast. let udvp = delegation_module .as_ref() - .map(|dm| -> StdResult { + .map(|dm| -> StdResult { deps.querier.query_wasm_smart( dm, &delegation::QueryMsg::UnvotedDelegatedVotingPower { @@ -257,9 +260,10 @@ pub fn get_voting_power_with_delegation( }, ) }) - .unwrap_or_else(|| Ok(Uint128::zero())) + .unwrap_or_else(|| Ok(UnvotedDelegatedVotingPowerResponse::default())) // fail gracefully if the query fails - .unwrap_or_default(); + .unwrap_or_default() + .effective; // sum both to get total voting power for this address on this proposal Ok(power.checked_add(udvp)?) From fe630a72068385f558a011302ca5979be50315d7 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Mon, 14 Oct 2024 16:26:32 -0400 Subject: [PATCH 11/24] add delegation expiry, and don't try to override inactive delegate's votes --- Cargo.lock | 1 + .../delegation/dao-vote-delegation/Cargo.toml | 1 + .../schema/dao-vote-delegation.json | 62 +++++- .../dao-vote-delegation/src/contract.rs | 191 ++++++++++-------- .../dao-vote-delegation/src/error.rs | 3 + .../dao-vote-delegation/src/helpers.rs | 67 +++++- .../dao-vote-delegation/src/hooks.rs | 43 ++-- .../delegation/dao-vote-delegation/src/msg.rs | 12 +- .../dao-vote-delegation/src/state.rs | 47 +++-- .../dao-proposal-multiple/src/contract.rs | 14 +- .../dao-proposal-single/src/contract.rs | 14 +- packages/cw-snapshot-vector-map/src/lib.rs | 14 +- packages/dao-voting/src/delegation.rs | 16 +- 13 files changed, 339 insertions(+), 146 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e8b51bdb..80a895016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2387,6 +2387,7 @@ dependencies = [ "cw-snapshot-vector-map", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", + "cw-wormhole", "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", diff --git a/contracts/delegation/dao-vote-delegation/Cargo.toml b/contracts/delegation/dao-vote-delegation/Cargo.toml index 0dc06bd31..0fb0d7957 100644 --- a/contracts/delegation/dao-vote-delegation/Cargo.toml +++ b/contracts/delegation/dao-vote-delegation/Cargo.toml @@ -25,6 +25,7 @@ cw-paginate-storage = { workspace = true } cw-snapshot-vector-map = { workspace = true } cw-storage-plus = { workspace = true } cw-utils = { workspace = true } +cw-wormhole = { workspace = true } dao-hooks = { workspace = true } dao-interface = { workspace = true } dao-voting = { workspace = true } diff --git a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json index 35bbb440a..579c96924 100644 --- a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json +++ b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json @@ -14,6 +14,15 @@ "null" ] }, + "delegation_validity_blocks": { + "description": "the number of blocks a delegation is valid for, after which it must be renewed by the delegator.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, "no_sync_proposal_modules": { "description": "Whether or not to sync proposal modules initially. If there are too many, the instantiation will run out of gas, so this should be disabled and `SyncProposalModules` called manually.\n\nDefaults to false.", "type": [ @@ -216,9 +225,18 @@ "update_config": { "type": "object", "required": [ + "delegation_validity_blocks", "vp_cap_percent" ], "properties": { + "delegation_validity_blocks": { + "description": "the number of blocks a delegation is valid for, after which it must be renewed by the delegator.", + "allOf": [ + { + "$ref": "#/definitions/OptionalUpdate_for_uint64" + } + ] + }, "vp_cap_percent": { "description": "the maximum percent of voting power that a single delegate can wield. they can be delegated any amount of voting power—this cap is only applied when casting votes.", "allOf": [ @@ -409,6 +427,17 @@ } ] }, + "OptionalUpdate_for_uint64": { + "description": "An update type that allows partial updates of optional fields.", + "anyOf": [ + { + "$ref": "#/definitions/Update_for_uint64" + }, + { + "type": "null" + } + ] + }, "StakeChangedHookMsg": { "description": "An enum representing staking hooks.", "oneOf": [ @@ -490,6 +519,30 @@ } ] }, + "Update_for_uint64": { + "oneOf": [ + { + "type": "string", + "enum": [ + "clear" + ] + }, + { + "type": "object", + "required": [ + "set" + ], + "properties": { + "set": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + ] + }, "VoteHookMsg": { "description": "An enum representing vote hooks, fired when new votes are cast.", "oneOf": [ @@ -821,7 +874,7 @@ "description": "The delegations.", "type": "array", "items": { - "$ref": "#/definitions/Delegation" + "$ref": "#/definitions/DelegationResponse" } }, "height": { @@ -841,13 +894,18 @@ "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", "type": "string" }, - "Delegation": { + "DelegationResponse": { "type": "object", "required": [ + "active", "delegate", "percent" ], "properties": { + "active": { + "description": "whether or not the delegation is active (i.e. the delegate is still registered at the corresponding block). this can only be false if the delegate was registered when the delegation was created and isn't anymore.", + "type": "boolean" + }, "delegate": { "description": "the delegate that can vote on behalf of the delegator.", "allOf": [ diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 5e779882b..846384b94 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -1,7 +1,7 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::{ entry_point, to_json_binary, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Response, - StdError, StdResult, Uint128, + StdResult, Uint128, }; use cosmwasm_std::{Addr, Order}; use cw2::{get_contract_version, set_contract_version}; @@ -11,12 +11,15 @@ use cw_utils::{maybe_addr, nonpayable}; use dao_interface::helpers::OptionalUpdate; use dao_interface::state::{ProposalModule, ProposalModuleStatus}; use dao_interface::voting::InfoResponse; -use dao_voting::delegation::{calculate_delegated_vp, UnvotedDelegatedVotingPowerResponse}; +use dao_voting::delegation::{ + calculate_delegated_vp, DelegationResponse, UnvotedDelegatedVotingPowerResponse, +}; use dao_voting::voting; use semver::Version; use crate::helpers::{ - ensure_setup, get_udvp, get_voting_power, is_delegate_registered, unregister_delegate, + add_delegated_vp, ensure_setup, get_udvp, get_voting_power, is_delegate_registered, + remove_delegated_vp, unregister_delegate, }; use crate::hooks::{ execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, execute_vote_hook, @@ -27,7 +30,8 @@ use crate::msg::{ }; use crate::state::{ Config, Delegate, Delegation, CONFIG, DAO, DELEGATED_VP, DELEGATES, DELEGATIONS, - DELEGATION_IDS, PERCENT_DELEGATED, PROPOSAL_HOOK_CALLERS, VOTING_POWER_HOOK_CALLERS, + DELEGATION_ENTRIES, PERCENT_DELEGATED, PROPOSAL_HOOK_CALLERS, VOTING_POWER_HOOK_CALLERS, + VP_CAP_PERCENT, }; use crate::ContractError; @@ -40,7 +44,7 @@ pub const MAX_LIMIT: u32 = 50; #[cfg_attr(not(feature = "library"), entry_point)] pub fn instantiate( deps: DepsMut, - _env: Env, + env: Env, info: MessageInfo, msg: InstantiateMsg, ) -> Result { @@ -54,12 +58,22 @@ pub fn instantiate( DAO.save(deps.storage, &dao)?; + if let Some(delegation_validity_blocks) = msg.delegation_validity_blocks { + if delegation_validity_blocks < 2 { + return Err(ContractError::InvalidDelegationValidityBlocks { + provided: delegation_validity_blocks, + min: 2, + }); + } + } + CONFIG.save( deps.storage, &Config { - vp_cap_percent: msg.vp_cap_percent, + delegation_validity_blocks: msg.delegation_validity_blocks, }, )?; + VP_CAP_PERCENT.save(deps.storage, &msg.vp_cap_percent, env.block.height)?; // sync proposal modules with no limit if not disabled. this should succeed // for most DAOs as the query will not run out of gas with only a few @@ -91,9 +105,10 @@ pub fn execute( ExecuteMsg::SyncProposalModules { start_after, limit } => { execute_sync_proposal_modules(deps, start_after, limit) } - ExecuteMsg::UpdateConfig { vp_cap_percent, .. } => { - execute_update_config(deps, info, vp_cap_percent) - } + ExecuteMsg::UpdateConfig { + vp_cap_percent, + delegation_validity_blocks, + } => execute_update_config(deps, env, info, vp_cap_percent, delegation_validity_blocks), ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), @@ -125,7 +140,7 @@ fn execute_register(deps: DepsMut, env: Env, info: MessageInfo) -> Result StdResult { - vp.unwrap_or_default() - // remove the current delegated VP from the delegate's total and - // replace it with the new delegated VP. if this is a new - // delegation, this will be zero. - .checked_sub(current_delegated_vp) - .map_err(StdError::overflow)? - .checked_add(new_delegated_vp) - .map_err(StdError::overflow) - }, + new_delegated_vp, + config.delegation_validity_blocks, )?; Ok(Response::new()) @@ -317,7 +319,7 @@ fn execute_undelegate( let delegate = deps.api.addr_validate(&delegate)?; // ensure delegation exists - let existing_id = DELEGATION_IDS + let (existing_id, existing_expiration) = DELEGATION_ENTRIES .load(deps.storage, (&delegator, &delegate)) .map_err(|_| ContractError::DelegationDoesNotExist {})?; @@ -326,7 +328,7 @@ fn execute_undelegate( // retrieve and remove delegation let delegation = DELEGATIONS.remove(deps.storage, &delegator, existing_id, env.block.height)?; - DELEGATION_IDS.remove(deps.storage, (&delegator, &delegate)); + DELEGATION_ENTRIES.remove(deps.storage, (&delegator, &delegate)); // update delegator's percent delegated let new_percent_delegated = current_percent_delegated.checked_sub(delegation.percent)?; @@ -341,29 +343,18 @@ fn execute_undelegate( env.block.height + 1, )?; - // remove delegated VP from delegate's total delegated VP + // remove delegated VP from delegate's total delegated VP at the current + // height. let current_delegated_vp = calculate_delegated_vp(vp, delegation.percent); - // this `update` function loads the latest delegated VP, even if it was - // updated before in this block, and then saves the new total at the current - // block, which will be reflected in historical queries starting from the - // NEXT block. if future delegations/undelegations/voting power changes - // occur in this block, they will immediately load the latest state, and - // update the total that will be reflected in historical queries starting - // from the next block. - DELEGATED_VP.update( + remove_delegated_vp( deps.storage, + &env, &delegate, - env.block.height, - |vp| -> StdResult { - vp - // must exist if delegation was added in the past - .ok_or(StdError::not_found("delegate's total delegated VP"))? - .checked_sub(current_delegated_vp) - .map_err(StdError::overflow) - }, + current_delegated_vp, + existing_expiration, )?; - Ok(Response::new()) + Ok(Response::new().add_attribute("action", "undelegate")) } fn execute_update_voting_power_hook_callers( @@ -426,8 +417,10 @@ fn execute_sync_proposal_modules( fn execute_update_config( deps: DepsMut, + env: Env, info: MessageInfo, vp_cap_percent: OptionalUpdate, + delegation_validity_blocks: OptionalUpdate, ) -> Result { nonpayable(&info)?; @@ -437,24 +430,42 @@ fn execute_update_config( return Err(ContractError::Unauthorized {}); } - let mut config = CONFIG.load(deps.storage)?; + vp_cap_percent + .maybe_update_result(|value| VP_CAP_PERCENT.save(deps.storage, &value, env.block.height))?; - vp_cap_percent.maybe_update(|value| { - config.vp_cap_percent = value; - }); + CONFIG.update(deps.storage, |mut config| -> Result<_, ContractError> { + delegation_validity_blocks.maybe_update_result(|value| { + // validate if defined + if let Some(value) = value { + if value < 2 { + return Err(ContractError::InvalidDelegationValidityBlocks { + provided: value, + min: 2, + }); + } + } - CONFIG.save(deps.storage, &config)?; + config.delegation_validity_blocks = value; - Ok(Response::new()) + Ok(()) + })?; + + Ok(config) + })?; + + Ok(Response::new().add_attribute("action", "update_config")) } #[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::Delegates { start_after, limit } => { - Ok(to_json_binary(&query_delegates(deps, start_after, limit)?)?) - } + QueryMsg::Delegates { start_after, limit } => Ok(to_json_binary(&query_delegates( + deps, + env, + start_after, + limit, + )?)?), QueryMsg::Delegations { delegator, height, @@ -491,6 +502,7 @@ fn query_info(deps: Deps) -> StdResult { fn query_delegates( deps: Deps, + env: Env, start_after: Option, limit: Option, ) -> StdResult { @@ -504,7 +516,7 @@ fn query_delegates( .map(|delegate| { delegate.map(|(delegate, _)| -> StdResult { let power = DELEGATED_VP - .may_load(deps.storage, &delegate)? + .load(deps.storage, delegate.clone(), env.block.height)? .unwrap_or_default(); Ok(DelegateResponse { delegate, power }) })? @@ -527,8 +539,15 @@ fn query_delegations( let delegations = DELEGATIONS .load(deps.storage, &delegator, height, limit, offset)? .into_iter() - .map(|d| d.item) - .collect(); + .map(|d| -> StdResult { + let active = is_delegate_registered(deps, &d.item.delegate, Some(height))?; + Ok(DelegationResponse { + delegate: d.item.delegate, + percent: d.item.percent, + active, + }) + }) + .collect::>()?; Ok(DelegationsResponse { delegations, height, @@ -558,8 +577,10 @@ fn query_unvoted_delegated_vp( let mut effective = total; // if a VP cap is set, apply it to the total VP to get the effective VP. - let config = CONFIG.load(deps.storage)?; - if let Some(vp_cap_percent) = config.vp_cap_percent { + let vp_cap_percent = VP_CAP_PERCENT + .may_load_at_height(deps.storage, height)? + .unwrap_or(None); + if let Some(vp_cap_percent) = vp_cap_percent { if vp_cap_percent < Decimal::one() { let dao = DAO.load(deps.storage)?; let total_power = voting::get_total_power(deps, &dao, Some(height))?; diff --git a/contracts/delegation/dao-vote-delegation/src/error.rs b/contracts/delegation/dao-vote-delegation/src/error.rs index 83d830f16..6e614f1d0 100644 --- a/contracts/delegation/dao-vote-delegation/src/error.rs +++ b/contracts/delegation/dao-vote-delegation/src/error.rs @@ -28,6 +28,9 @@ pub enum ContractError { #[error("unauthorized hook caller")] UnauthorizedHookCaller {}, + #[error("invalid delegation validity blocks: provided {provided}, minimum {min}")] + InvalidDelegationValidityBlocks { provided: u64, min: u64 }, + #[error("delegate already registered")] DelegateAlreadyRegistered {}, diff --git a/contracts/delegation/dao-vote-delegation/src/helpers.rs b/contracts/delegation/dao-vote-delegation/src/helpers.rs index a712ebc11..63ae3bb35 100644 --- a/contracts/delegation/dao-vote-delegation/src/helpers.rs +++ b/contracts/delegation/dao-vote-delegation/src/helpers.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::{Addr, Deps, DepsMut, StdResult, Uint128}; +use cosmwasm_std::{Addr, Deps, DepsMut, Env, StdResult, Storage, Uint128}; use dao_voting::voting; @@ -49,7 +49,7 @@ pub fn get_udvp( match UNVOTED_DELEGATED_VP.may_load(deps.storage, (delegate, proposal_module, proposal_id))? { Some(vp) => Ok(vp), None => Ok(DELEGATED_VP - .may_load_at_height(deps.storage, delegate, height)? + .load(deps.storage, delegate.clone(), height)? .unwrap_or_default()), } } @@ -64,3 +64,66 @@ pub fn ensure_setup(deps: Deps) -> Result<(), ContractError> { Ok(()) } + +/// Add delegated VP from a delegator to a delegate, potentially with a given +/// expiration. +pub fn add_delegated_vp( + storage: &mut dyn Storage, + env: &Env, + delegate: &Addr, + vp: Uint128, + expire_in: Option, +) -> StdResult<()> { + DELEGATED_VP.increment( + storage, + delegate.clone(), + // update at next block height to match 1-block delay behavior of voting + // power queries and delegation changes. this matches the behavior of + // creating a new delegation, which also starts on the following block. + // if future delegations/undelegations/voting power changes occur in + // this block, they will also load the state of the next block and + // update the total that will be reflected in historical queries + // starting from the next block. + env.block.height + 1, + vp, + )?; + + // if expiration exists, decrement in the future at expiration height + if let Some(expire_in) = expire_in { + DELEGATED_VP.decrement(storage, delegate.clone(), env.block.height + expire_in, vp)?; + } + + Ok(()) +} + +/// Remove delegated VP from a delegate, potentially with a given expiration. +pub fn remove_delegated_vp( + storage: &mut dyn Storage, + env: &Env, + delegate: &Addr, + vp: Uint128, + original_expiration: Option, +) -> StdResult<()> { + // if expiration was used when creating this delegation, first undo previous + // decrement at end of expiration period. do this before undoing previous + // increment to prevent underflow. + if let Some(expiration) = original_expiration { + DELEGATED_VP.increment(storage, delegate.clone(), expiration, vp)?; + } + + DELEGATED_VP.decrement( + storage, + delegate.clone(), + // update at next block height to match 1-block delay behavior of voting + // power queries and delegation changes. this matches the behavior of + // creating a new delegation, which also starts on the following block. + // if future delegations/undelegations/voting power changes occur in + // this block, they will also load the state of the next block and + // update the total that will be reflected in historical queries + // starting from the next block. + env.block.height + 1, + vp, + )?; + + Ok(()) +} diff --git a/contracts/delegation/dao-vote-delegation/src/hooks.rs b/contracts/delegation/dao-vote-delegation/src/hooks.rs index 7fde3a5cf..ff21a9d9f 100644 --- a/contracts/delegation/dao-vote-delegation/src/hooks.rs +++ b/contracts/delegation/dao-vote-delegation/src/hooks.rs @@ -1,13 +1,16 @@ -use cosmwasm_std::{Addr, DepsMut, Env, MessageInfo, Response, StdError, StdResult, Uint128}; +use cosmwasm_std::{Addr, DepsMut, Env, MessageInfo, Response}; use cw4::MemberChangedHookMsg; use cw_snapshot_vector_map::LoadedItem; use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg, vote::VoteHookMsg}; use dao_voting::delegation::calculate_delegated_vp; use crate::{ - helpers::{get_udvp, get_voting_power, is_delegate_registered, unregister_delegate}, + helpers::{ + add_delegated_vp, get_udvp, get_voting_power, is_delegate_registered, remove_delegated_vp, + unregister_delegate, + }, state::{ - Delegation, DELEGATED_VP, DELEGATIONS, PROPOSAL_HOOK_CALLERS, UNVOTED_DELEGATED_VP, + Delegation, CONFIG, DELEGATIONS, PROPOSAL_HOOK_CALLERS, UNVOTED_DELEGATED_VP, VOTING_POWER_HOOK_CALLERS, }, ContractError, @@ -119,8 +122,11 @@ pub(crate) fn handle_voting_power_changed_hook( let delegations = DELEGATIONS.load_all_latest(deps.storage, &delegator, env.block.height)?; + let config = CONFIG.load(deps.storage)?; + for LoadedItem { item: Delegation { delegate, percent }, + expiration, .. } in delegations { @@ -129,25 +135,22 @@ pub(crate) fn handle_voting_power_changed_hook( let current_delegated_vp = calculate_delegated_vp(old_vp, percent); let new_delegated_vp = calculate_delegated_vp(new_vp, percent); - // this `update` function loads the latest delegated VP, even if it - // was updated before in this block, and then saves the new total at - // the current block, which will be reflected in historical queries - // starting from the NEXT block. if future - // delegations/undelegations/voting power changes occur in this - // block, they will immediately load the latest state, and update - // the total that will be reflected in historical queries starting - // from the next block. - DELEGATED_VP.update( + // remove original delegated VP + remove_delegated_vp( + deps.storage, + env, + &delegate, + current_delegated_vp, + expiration, + )?; + + // add new delegated VP + add_delegated_vp( deps.storage, + env, &delegate, - env.block.height, - |vp| -> StdResult { - vp.unwrap_or_default() - .checked_sub(current_delegated_vp) - .map_err(StdError::overflow)? - .checked_add(new_delegated_vp) - .map_err(StdError::overflow) - }, + new_delegated_vp, + config.delegation_validity_blocks, )?; } } diff --git a/contracts/delegation/dao-vote-delegation/src/msg.rs b/contracts/delegation/dao-vote-delegation/src/msg.rs index be418e8a4..c1a332366 100644 --- a/contracts/delegation/dao-vote-delegation/src/msg.rs +++ b/contracts/delegation/dao-vote-delegation/src/msg.rs @@ -25,9 +25,9 @@ pub struct InstantiateMsg { /// they can be delegated any amount of voting power—this cap is only /// applied when casting votes. pub vp_cap_percent: Option, - // /// the duration a delegation is valid for, after which it must be renewed - // /// by the delegator. - // pub delegation_validity: Option, + /// the number of blocks a delegation is valid for, after which it must be + /// renewed by the delegator. + pub delegation_validity_blocks: Option, } #[cw_serde] @@ -70,9 +70,9 @@ pub enum ExecuteMsg { /// wield. they can be delegated any amount of voting power—this cap is /// only applied when casting votes. vp_cap_percent: OptionalUpdate, - // /// the duration a delegation is valid for, after which it must be - // /// renewed by the delegator. - // delegation_validity: Option, + /// the number of blocks a delegation is valid for, after which it must + /// be renewed by the delegator. + delegation_validity_blocks: OptionalUpdate, }, /// Called when a member is added or removed /// to a cw4-groups or cw721-roles contract. diff --git a/contracts/delegation/dao-vote-delegation/src/state.rs b/contracts/delegation/dao-vote-delegation/src/state.rs index 86a577932..df3a029b9 100644 --- a/contracts/delegation/dao-vote-delegation/src/state.rs +++ b/contracts/delegation/dao-vote-delegation/src/state.rs @@ -1,7 +1,9 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{Addr, Decimal, Uint128}; use cw_snapshot_vector_map::SnapshotVectorMap; -use cw_storage_plus::{Item, Map, SnapshotMap, Strategy}; +use cw_storage_plus::{Item, Map, SnapshotItem, SnapshotMap, Strategy}; + +use cw_wormhole::Wormhole; // make these types directly available to consumers of this crate pub use dao_voting::delegation::{Delegate, Delegation}; @@ -9,6 +11,19 @@ pub use dao_voting::delegation::{Delegate, Delegation}; /// the configuration of the delegation system. pub const CONFIG: Item = Item::new("config"); +/// the maximum percent of voting power that a single delegate can wield. they +/// can be delegated any amount of voting power—this cap is only applied when +/// casting votes. historical values must be stored so that proposals that +/// already exist use deterministic math. +/// +/// this is separate from config since we need historical queries for it. +pub const VP_CAP_PERCENT: SnapshotItem> = SnapshotItem::new( + "vpc", + "vpc__checkpoints", + "vpc__changelog", + Strategy::EveryBlock, +); + /// the DAO this delegation system is connected to. pub const DAO: Item = Item::new("dao"); @@ -33,13 +48,10 @@ pub const DELEGATES: SnapshotMap = SnapshotMap::new( /// specific proposal. pub const UNVOTED_DELEGATED_VP: Map<(&Addr, &Addr, u64), Uint128> = Map::new("udvp"); -/// the VP delegated to a delegate by height. -pub const DELEGATED_VP: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( - "dvp", - "dvp__checkpoints", - "dvp__changelog", - Strategy::EveryBlock, -); +/// the VP delegated to a delegate by height. Wormhole allows us to update +/// delegated VP in the future, which we need for implementing automatic +/// delegation expiration. +pub const DELEGATED_VP: Wormhole = Wormhole::new("dvp"); /// the delegations of a delegator. pub const DELEGATIONS: SnapshotVectorMap = SnapshotVectorMap::new( @@ -50,22 +62,17 @@ pub const DELEGATIONS: SnapshotVectorMap = SnapshotVectorMap:: "d__active__changelog", ); -/// map (delegator, delegate) -> ID of the delegation in the vector map. this is -/// useful for quickly checking if a delegation already exists, and for -/// undelegating. -pub const DELEGATION_IDS: Map<(&Addr, &Addr), u64> = Map::new("dids"); +/// map (delegator, delegate) -> (ID, expiration_block) of the delegation in the +/// vector map. this is useful for quickly checking if a delegation already +/// exists, and for undelegating. +pub const DELEGATION_ENTRIES: Map<(&Addr, &Addr), (u64, Option)> = Map::new("dids"); /// map delegator -> percent delegated to all delegates. pub const PERCENT_DELEGATED: Map<&Addr, Decimal> = Map::new("pd"); #[cw_serde] pub struct Config { - /// the maximum percent of voting power that a single delegate can wield. - /// they can be delegated any amount of voting power—this cap is only - /// applied when casting votes. - pub vp_cap_percent: Option, - // TODO: expiry?? wormhole???? - // /// the duration a delegation is valid for, after which it must be renewed - // /// by the delegator. - // pub delegation_validity: Option, + /// the number of blocks a delegation is valid for, after which it must be + /// renewed by the delegator. + pub delegation_validity_blocks: Option, } diff --git a/contracts/proposal/dao-proposal-multiple/src/contract.rs b/contracts/proposal/dao-proposal-multiple/src/contract.rs index 98749bb04..63e99427f 100644 --- a/contracts/proposal/dao-proposal-multiple/src/contract.rs +++ b/contracts/proposal/dao-proposal-multiple/src/contract.rs @@ -15,7 +15,7 @@ use dao_hooks::proposal::{ use dao_hooks::vote::new_vote_hooks; use dao_interface::voting::IsActiveResponse; use dao_voting::delegation::{ - self, calculate_delegated_vp, Delegation, UnvotedDelegatedVotingPowerResponse, + self, calculate_delegated_vp, DelegationResponse, UnvotedDelegatedVotingPowerResponse, }; use dao_voting::voting::get_voting_power_with_delegation; use dao_voting::{ @@ -478,7 +478,17 @@ pub fn execute_vote( )? .delegations; - for Delegation { delegate, percent } in delegations { + for DelegationResponse { + delegate, + percent, + active, + } in delegations + { + // if delegation is not active, skip. + if !active { + continue; + } + // if delegate voted already, untally the VP the delegator // delegated to them since the delegate's vote is being // overridden. diff --git a/contracts/proposal/dao-proposal-single/src/contract.rs b/contracts/proposal/dao-proposal-single/src/contract.rs index d343e5488..b6286b26e 100644 --- a/contracts/proposal/dao-proposal-single/src/contract.rs +++ b/contracts/proposal/dao-proposal-single/src/contract.rs @@ -14,7 +14,7 @@ use dao_hooks::proposal::{ use dao_hooks::vote::new_vote_hooks; use dao_interface::voting::IsActiveResponse; use dao_voting::delegation::{ - self, calculate_delegated_vp, Delegation, UnvotedDelegatedVotingPowerResponse, + self, calculate_delegated_vp, DelegationResponse, UnvotedDelegatedVotingPowerResponse, }; use dao_voting::pre_propose::{PreProposeInfo, ProposalCreationPolicy}; use dao_voting::proposal::{ @@ -583,7 +583,17 @@ pub fn execute_vote( )? .delegations; - for Delegation { delegate, percent } in delegations { + for DelegationResponse { + delegate, + percent, + active, + } in delegations + { + // if delegation is not active, skip. + if !active { + continue; + } + // if delegate voted already, untally the VP the delegator // delegated to them since the delegate's vote is being // overridden. diff --git a/packages/cw-snapshot-vector-map/src/lib.rs b/packages/cw-snapshot-vector-map/src/lib.rs index 3711b9877..b4850e2f2 100644 --- a/packages/cw-snapshot-vector-map/src/lib.rs +++ b/packages/cw-snapshot-vector-map/src/lib.rs @@ -77,9 +77,10 @@ where for<'b> &'b (K, u64): PrimaryKey<'b>, { /// Adds an item to the vector at the current block height, optionally - /// expiring in the future, returning the ID of the new item. This block - /// should be greater than or equal to the blocks all previous items were - /// added/removed at. Pushing to the past will lead to incorrect behavior. + /// expiring in the future, returning the ID and potentially the expiration + /// height of the new item. This block should be greater than or equal to + /// the blocks all previous items were added/removed at. Pushing to the past + /// will lead to incorrect behavior. pub fn push( &self, store: &mut dyn Storage, @@ -87,7 +88,7 @@ where data: &V, curr_height: u64, expire_in: Option, - ) -> StdResult { + ) -> StdResult<(u64, Option)> { // get next ID for the key, defaulting to 0 let next_id = self .next_ids @@ -106,7 +107,8 @@ where }); // add new item and save list - active.push((next_id, expire_in.map(|d| curr_height + d))); + let expiration = expire_in.map(|d| curr_height + d); + active.push((next_id, expiration)); // save the new list self.active.save(store, k.clone(), &active, curr_height)?; @@ -114,7 +116,7 @@ where // update next ID self.next_ids.save(store, k.clone(), &(next_id + 1))?; - Ok(next_id) + Ok((next_id, expiration)) } /// Removes an item from the vector by ID and returns it. The block height diff --git a/packages/dao-voting/src/delegation.rs b/packages/dao-voting/src/delegation.rs index 7fee0d755..9387e9ff4 100644 --- a/packages/dao-voting/src/delegation.rs +++ b/packages/dao-voting/src/delegation.rs @@ -68,11 +68,25 @@ pub struct DelegateResponse { #[derive(Default)] pub struct DelegationsResponse { /// The delegations. - pub delegations: Vec, + pub delegations: Vec, /// The height at which the delegations were loaded. pub height: u64, } +#[cw_serde] +pub struct DelegationResponse { + /// the delegate that can vote on behalf of the delegator. + pub delegate: Addr, + /// the percent of the delegator's voting power that is delegated to the + /// delegate. + pub percent: Decimal, + /// whether or not the delegation is active (i.e. the delegate is still + /// registered at the corresponding block). this can only be false if the + /// delegate was registered when the delegation was created and isn't + /// anymore. + pub active: bool, +} + #[cw_serde] #[derive(Default)] pub struct UnvotedDelegatedVotingPowerResponse { From c7ef28d39659f594ead2bfbb6242b5a4d2905bcd Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 17 Oct 2024 01:28:16 -0400 Subject: [PATCH 12/24] renamed member1-5 to addr0-4 --- .../src/testing/suite.rs | 28 +- .../src/testing/tests.rs | 1322 ++++++++--------- packages/dao-testing/src/suite/cw20_suite.rs | 10 +- packages/dao-testing/src/suite/cw4_suite.rs | 10 +- packages/dao-testing/src/suite/cw721_suite.rs | 16 +- packages/dao-testing/src/suite/mod.rs | 10 +- packages/dao-testing/src/suite/token_suite.rs | 10 +- 7 files changed, 701 insertions(+), 705 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs index aadf43a72..1153fc548 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -8,7 +8,7 @@ use cw_utils::Duration; use dao_interface::{token::InitialBalance, voting::InfoResponse}; use dao_testing::{ Cw20TestDao, Cw4TestDao, Cw721TestDao, DaoTestingSuite, DaoTestingSuiteBase, InitialNft, - TokenTestDao, GOV_DENOM, MEMBER1, MEMBER2, MEMBER3, OWNER, + TokenTestDao, ADDR0, ADDR1, ADDR2, GOV_DENOM, OWNER, }; use crate::{ @@ -54,15 +54,15 @@ impl SuiteBuilder { }, cw4_members: vec![ Member { - addr: MEMBER1.to_string(), + addr: ADDR0.to_string(), weight: 2, }, Member { - addr: MEMBER2.to_string(), + addr: ADDR1.to_string(), weight: 1, }, Member { - addr: MEMBER3.to_string(), + addr: ADDR2.to_string(), weight: 1, }, ], @@ -116,15 +116,15 @@ impl SuiteBuilder { .cw20() .with_initial_balances(vec![ Cw20Coin { - address: MEMBER1.to_string(), + address: ADDR0.to_string(), amount: Uint128::new(100), }, Cw20Coin { - address: MEMBER2.to_string(), + address: ADDR1.to_string(), amount: Uint128::new(50), }, Cw20Coin { - address: MEMBER3.to_string(), + address: ADDR2.to_string(), amount: Uint128::new(50), }, ]) @@ -143,19 +143,19 @@ impl SuiteBuilder { .with_initial_nfts(vec![ InitialNft { token_id: "1".to_string(), - owner: MEMBER1.to_string(), + owner: ADDR0.to_string(), }, InitialNft { token_id: "2".to_string(), - owner: MEMBER1.to_string(), + owner: ADDR0.to_string(), }, InitialNft { token_id: "3".to_string(), - owner: MEMBER2.to_string(), + owner: ADDR1.to_string(), }, InitialNft { token_id: "4".to_string(), - owner: MEMBER3.to_string(), + owner: ADDR2.to_string(), }, ]) .dao(); @@ -171,15 +171,15 @@ impl SuiteBuilder { .token() .with_initial_balances(vec![ InitialBalance { - address: MEMBER1.to_string(), + address: ADDR0.to_string(), amount: Uint128::new(100), }, InitialBalance { - address: MEMBER2.to_string(), + address: ADDR1.to_string(), amount: Uint128::new(50), }, InitialBalance { - address: MEMBER3.to_string(), + address: ADDR2.to_string(), amount: Uint128::new(50), }, ]) diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index 5f3dd4a10..f02a17a98 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -8,7 +8,7 @@ use cw_multi_test::Executor; use cw_ownable::OwnershipError; use cw_utils::Duration; use dao_interface::voting::InfoResponse; -use dao_testing::{DaoTestingSuite, GOV_DENOM, MEMBER1, MEMBER2, MEMBER3, MEMBER4, OWNER}; +use dao_testing::{DaoTestingSuite, ADDR0, ADDR1, ADDR2, ADDR3, GOV_DENOM, OWNER}; use crate::contract::{CONTRACT_NAME, CONTRACT_VERSION}; use crate::msg::ExecuteMsg; @@ -74,24 +74,24 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); suite.assert_undistributed_rewards(1, 90_000_000); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); suite.assert_undistributed_rewards(1, 80_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_pending_rewards(ADDR0, 1, 0); // set the rewards rate to half of the current one // now there will be 5_000_000 tokens distributed over 100_000 blocks @@ -100,9 +100,9 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER2, 1, 6_250_000); - suite.assert_pending_rewards(MEMBER3, 1, 6_250_000); + suite.assert_pending_rewards(ADDR0, 1, 2_500_000); + suite.assert_pending_rewards(ADDR1, 1, 6_250_000); + suite.assert_pending_rewards(ADDR2, 1, 6_250_000); suite.assert_undistributed_rewards(1, 75_000_000); @@ -113,18 +113,18 @@ fn test_native_dao_rewards_update_reward_rate() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 7_500_000); - suite.assert_pending_rewards(MEMBER2, 1, 8_750_000); - suite.assert_pending_rewards(MEMBER3, 1, 8_750_000); + suite.assert_pending_rewards(ADDR0, 1, 7_500_000); + suite.assert_pending_rewards(ADDR1, 1, 8_750_000); + suite.assert_pending_rewards(ADDR2, 1, 8_750_000); suite.assert_undistributed_rewards(1, 65_000_000); // skip 2/10ths of the time suite.skip_blocks(200_000); - suite.assert_pending_rewards(MEMBER1, 1, 17_500_000); - suite.assert_pending_rewards(MEMBER2, 1, 13_750_000); - suite.assert_pending_rewards(MEMBER3, 1, 13_750_000); + suite.assert_pending_rewards(ADDR0, 1, 17_500_000); + suite.assert_pending_rewards(ADDR1, 1, 13_750_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); suite.assert_undistributed_rewards(1, 45_000_000); @@ -135,44 +135,44 @@ fn test_native_dao_rewards_update_reward_rate() { suite.skip_blocks(100_000); // assert no pending rewards changed - suite.assert_pending_rewards(MEMBER1, 1, 17_500_000); - suite.assert_pending_rewards(MEMBER2, 1, 13_750_000); - suite.assert_pending_rewards(MEMBER3, 1, 13_750_000); + suite.assert_pending_rewards(ADDR0, 1, 17_500_000); + suite.assert_pending_rewards(ADDR1, 1, 13_750_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); suite.assert_undistributed_rewards(1, 45_000_000); - // assert MEMBER1 pre-claim balance - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000); - // MEMBER1 claims their rewards - suite.claim_rewards(MEMBER1, 1); - // assert MEMBER1 post-claim balance to be pre-claim + pending - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000 + 17_500_000); - // assert MEMBER1 is now entitled to 0 pending rewards - suite.assert_pending_rewards(MEMBER1, 1, 0); + // assert ADDR0 pre-claim balance + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000); + // ADDR0 claims their rewards + suite.claim_rewards(ADDR0, 1); + // assert ADDR0 post-claim balance to be pre-claim + pending + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000 + 17_500_000); + // assert ADDR0 is now entitled to 0 pending rewards + suite.assert_pending_rewards(ADDR0, 1, 0); // user 2 unstakes their stake - suite.unstake_native_tokens(MEMBER2, 50); + suite.unstake_native_tokens(ADDR1, 50); // skip 1/10th of the time suite.skip_blocks(100_000); - // only the MEMBER1 pending rewards should have changed - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 13_750_000); - suite.assert_pending_rewards(MEMBER3, 1, 13_750_000); + // only the ADDR0 pending rewards should have changed + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 13_750_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); suite.assert_undistributed_rewards(1, 45_000_000); - // MEMBER2 claims their rewards (has 50 to begin with as they unstaked) - suite.assert_native_balance(MEMBER2, GOV_DENOM, 50); - suite.claim_rewards(MEMBER2, 1); - // assert MEMBER2 post-claim balance to be pre-claim + pending and has 0 pending rewards - suite.assert_native_balance(MEMBER2, GOV_DENOM, 13_750_000 + 50); - suite.assert_pending_rewards(MEMBER2, 1, 0); + // ADDR1 claims their rewards (has 50 to begin with as they unstaked) + suite.assert_native_balance(ADDR1, GOV_DENOM, 50); + suite.claim_rewards(ADDR1, 1); + // assert ADDR1 post-claim balance to be pre-claim + pending and has 0 pending rewards + suite.assert_native_balance(ADDR1, GOV_DENOM, 13_750_000 + 50); + suite.assert_pending_rewards(ADDR1, 1, 0); // update the reward rate back to 1_000 / 10blocks // this should now distribute 10_000_000 tokens over 100_000 blocks - // between MEMBER1 (2/3rds) and MEMBER3 (1/3rd) + // between ADDR0 (2/3rds) and ADDR2 (1/3rd) suite.update_emission_rate(1, Duration::Height(10), 1000, true); // update with the same rate does nothing @@ -182,37 +182,37 @@ fn test_native_dao_rewards_update_reward_rate() { suite.skip_blocks(100_000); // assert that rewards are being distributed at the expected rate - suite.assert_pending_rewards(MEMBER1, 1, 6_666_666); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 13_750_000 + 3_333_333); + suite.assert_pending_rewards(ADDR0, 1, 6_666_666); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000 + 3_333_333); suite.assert_undistributed_rewards(1, 35_000_000); - // MEMBER3 claims their rewards - suite.assert_native_balance(MEMBER3, GOV_DENOM, 0); - suite.claim_rewards(MEMBER3, 1); - suite.assert_pending_rewards(MEMBER3, 1, 0); - suite.assert_native_balance(MEMBER3, GOV_DENOM, 13_750_000 + 3_333_333); + // ADDR2 claims their rewards + suite.assert_native_balance(ADDR2, GOV_DENOM, 0); + suite.claim_rewards(ADDR2, 1); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_native_balance(ADDR2, GOV_DENOM, 13_750_000 + 3_333_333); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 6_666_666 + 6_666_666 + 1); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 3_333_333); + suite.assert_pending_rewards(ADDR0, 1, 6_666_666 + 6_666_666 + 1); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 3_333_333); suite.assert_undistributed_rewards(1, 25_000_000); // claim everything so that there are 0 pending rewards - suite.claim_rewards(MEMBER3, 1); - suite.claim_rewards(MEMBER1, 1); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR0, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 0); + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); // update the rewards rate to 40_000_000 per 100_000 blocks. - // split is still 2/3rds to MEMBER1 and 1/3rd to MEMBER3 + // split is still 2/3rds to ADDR0 and 1/3rd to ADDR2 suite.update_emission_rate(1, Duration::Height(10), 4000, true); suite.assert_ends_at(Expiration::AtHeight(1_062_500)); @@ -220,35 +220,35 @@ fn test_native_dao_rewards_update_reward_rate() { let addr1_pending = 20_000_000 * 2 / 3; let addr3_pending = 20_000_000 / 3; - suite.assert_pending_rewards(MEMBER1, 1, addr1_pending); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, addr3_pending); + suite.assert_pending_rewards(ADDR0, 1, addr1_pending); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, addr3_pending); suite.assert_undistributed_rewards(1, 5_000_000); - // MEMBER2 wakes up to the increased staking rate and stakes 50 tokens - // this brings new split to: [MEMBER1: 50%, MEMBER2: 25%, MEMBER3: 25%] - suite.stake_native_tokens(MEMBER2, 50); + // ADDR1 wakes up to the increased staking rate and stakes 50 tokens + // this brings new split to: [ADDR0: 50%, ADDR1: 25%, ADDR2: 25%] + suite.stake_native_tokens(ADDR1, 50); suite.skip_blocks(10_000); // allocates 4_000_000 tokens - suite.assert_pending_rewards(MEMBER1, 1, addr1_pending + 4_000_000 * 2 / 4); - suite.assert_pending_rewards(MEMBER2, 1, 4_000_000 / 4); - suite.assert_pending_rewards(MEMBER3, 1, addr3_pending + 4_000_000 / 4); + suite.assert_pending_rewards(ADDR0, 1, addr1_pending + 4_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR1, 1, 4_000_000 / 4); + suite.assert_pending_rewards(ADDR2, 1, addr3_pending + 4_000_000 / 4); suite.assert_undistributed_rewards(1, 1_000_000); - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR2, 1); let addr1_pending = 0; let addr3_pending = 0; suite.skip_blocks(10_000); // skips from 1,060,000 to 1,070,000, and the end is 1,062,500, so this allocates only 1_000_000 tokens instead of 4_000_000 - suite.assert_pending_rewards(MEMBER1, 1, addr1_pending + 1_000_000 * 2 / 4); - suite.assert_pending_rewards(MEMBER2, 1, 4_000_000 / 4 + 1_000_000 / 4); - suite.assert_pending_rewards(MEMBER3, 1, addr3_pending + 1_000_000 / 4); + suite.assert_pending_rewards(ADDR0, 1, addr1_pending + 1_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR1, 1, 4_000_000 / 4 + 1_000_000 / 4); + suite.assert_pending_rewards(ADDR2, 1, addr3_pending + 1_000_000 / 4); - suite.claim_rewards(MEMBER2, 1); + suite.claim_rewards(ADDR1, 1); suite.assert_undistributed_rewards(1, 0); @@ -277,20 +277,20 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_pending_rewards(ADDR0, 1, 0); // set the rewards rate to time-based rewards suite.update_emission_rate(1, Duration::Time(10), 500, true); @@ -298,9 +298,9 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER2, 1, 6_250_000); - suite.assert_pending_rewards(MEMBER3, 1, 6_250_000); + suite.assert_pending_rewards(ADDR0, 1, 2_500_000); + suite.assert_pending_rewards(ADDR1, 1, 6_250_000); + suite.assert_pending_rewards(ADDR2, 1, 6_250_000); // double the rewards rate // now there will be 10_000_000 tokens distributed over 100_000 seconds @@ -309,16 +309,16 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 7_500_000); - suite.assert_pending_rewards(MEMBER2, 1, 8_750_000); - suite.assert_pending_rewards(MEMBER3, 1, 8_750_000); + suite.assert_pending_rewards(ADDR0, 1, 7_500_000); + suite.assert_pending_rewards(ADDR1, 1, 8_750_000); + suite.assert_pending_rewards(ADDR2, 1, 8_750_000); // skip 2/10ths of the time suite.skip_seconds(200_000); - suite.assert_pending_rewards(MEMBER1, 1, 17_500_000); - suite.assert_pending_rewards(MEMBER2, 1, 13_750_000); - suite.assert_pending_rewards(MEMBER3, 1, 13_750_000); + suite.assert_pending_rewards(ADDR0, 1, 17_500_000); + suite.assert_pending_rewards(ADDR1, 1, 13_750_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); // pause the rewards distribution suite.pause_emission(1); @@ -327,73 +327,73 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { suite.skip_blocks(100_000); // assert no pending rewards changed - suite.assert_pending_rewards(MEMBER1, 1, 17_500_000); - suite.assert_pending_rewards(MEMBER2, 1, 13_750_000); - suite.assert_pending_rewards(MEMBER3, 1, 13_750_000); - - // assert MEMBER1 pre-claim balance - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000); - // MEMBER1 claims their rewards - suite.claim_rewards(MEMBER1, 1); - // assert MEMBER1 post-claim balance to be pre-claim + pending - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000 + 17_500_000); - // assert MEMBER1 is now entitled to 0 pending rewards - suite.assert_pending_rewards(MEMBER1, 1, 0); + suite.assert_pending_rewards(ADDR0, 1, 17_500_000); + suite.assert_pending_rewards(ADDR1, 1, 13_750_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); + + // assert ADDR0 pre-claim balance + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000); + // ADDR0 claims their rewards + suite.claim_rewards(ADDR0, 1); + // assert ADDR0 post-claim balance to be pre-claim + pending + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000 + 17_500_000); + // assert ADDR0 is now entitled to 0 pending rewards + suite.assert_pending_rewards(ADDR0, 1, 0); // user 2 unstakes their stake - suite.unstake_native_tokens(MEMBER2, 50); + suite.unstake_native_tokens(ADDR1, 50); // skip 1/10th of the time suite.skip_blocks(100_000); - // only the MEMBER1 pending rewards should have changed - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 13_750_000); - suite.assert_pending_rewards(MEMBER3, 1, 13_750_000); + // only the ADDR0 pending rewards should have changed + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 13_750_000); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000); - // MEMBER2 claims their rewards (has 50 to begin with as they unstaked) - suite.assert_native_balance(MEMBER2, GOV_DENOM, 50); - suite.claim_rewards(MEMBER2, 1); - // assert MEMBER2 post-claim balance to be pre-claim + pending and has 0 pending rewards - suite.assert_native_balance(MEMBER2, GOV_DENOM, 13_750_000 + 50); - suite.assert_pending_rewards(MEMBER2, 1, 0); + // ADDR1 claims their rewards (has 50 to begin with as they unstaked) + suite.assert_native_balance(ADDR1, GOV_DENOM, 50); + suite.claim_rewards(ADDR1, 1); + // assert ADDR1 post-claim balance to be pre-claim + pending and has 0 pending rewards + suite.assert_native_balance(ADDR1, GOV_DENOM, 13_750_000 + 50); + suite.assert_pending_rewards(ADDR1, 1, 0); // update the reward rate back to 1_000 / 10blocks // this should now distribute 10_000_000 tokens over 100_000 blocks - // between MEMBER1 (2/3rds) and MEMBER3 (1/3rd) + // between ADDR0 (2/3rds) and ADDR2 (1/3rd) suite.update_emission_rate(1, Duration::Height(10), 1000, true); // skip 1/10th of the time suite.skip_blocks(100_000); // assert that rewards are being distributed at the expected rate - suite.assert_pending_rewards(MEMBER1, 1, 6_666_666); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 13_750_000 + 3_333_333); + suite.assert_pending_rewards(ADDR0, 1, 6_666_666); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 13_750_000 + 3_333_333); - // MEMBER3 claims their rewards - suite.assert_native_balance(MEMBER3, GOV_DENOM, 0); - suite.claim_rewards(MEMBER3, 1); - suite.assert_pending_rewards(MEMBER3, 1, 0); - suite.assert_native_balance(MEMBER3, GOV_DENOM, 13_750_000 + 3_333_333); + // ADDR2 claims their rewards + suite.assert_native_balance(ADDR2, GOV_DENOM, 0); + suite.claim_rewards(ADDR2, 1); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_native_balance(ADDR2, GOV_DENOM, 13_750_000 + 3_333_333); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 6_666_666 + 6_666_666 + 1); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 3_333_333); + suite.assert_pending_rewards(ADDR0, 1, 6_666_666 + 6_666_666 + 1); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 3_333_333); // claim everything so that there are 0 pending rewards - suite.claim_rewards(MEMBER3, 1); - suite.claim_rewards(MEMBER1, 1); + suite.claim_rewards(ADDR2, 1); + suite.claim_rewards(ADDR0, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 0); + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); // update the rewards rate to 40_000_000 per 100_000 seconds. - // split is still 2/3rds to MEMBER1 and 1/3rd to MEMBER3 + // split is still 2/3rds to ADDR0 and 1/3rd to ADDR2 suite.update_emission_rate(1, Duration::Time(10), 4000, true); suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds(462_500))); @@ -401,31 +401,31 @@ fn test_native_dao_rewards_reward_rate_switch_unit() { let addr1_pending = 20_000_000 * 2 / 3; let addr3_pending = 20_000_000 / 3; - suite.assert_pending_rewards(MEMBER1, 1, addr1_pending); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, addr3_pending); + suite.assert_pending_rewards(ADDR0, 1, addr1_pending); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, addr3_pending); - // MEMBER2 wakes up to the increased staking rate and stakes 50 tokens - // this brings new split to: [MEMBER1: 50%, MEMBER2: 25%, MEMBER3: 25%] - suite.stake_native_tokens(MEMBER2, 50); + // ADDR1 wakes up to the increased staking rate and stakes 50 tokens + // this brings new split to: [ADDR0: 50%, ADDR1: 25%, ADDR2: 25%] + suite.stake_native_tokens(ADDR1, 50); suite.skip_seconds(10_000); // allocates 4_000_000 tokens - suite.assert_pending_rewards(MEMBER1, 1, addr1_pending + 4_000_000 * 2 / 4); - suite.assert_pending_rewards(MEMBER2, 1, 4_000_000 / 4); - suite.assert_pending_rewards(MEMBER3, 1, addr3_pending + 4_000_000 / 4); + suite.assert_pending_rewards(ADDR0, 1, addr1_pending + 4_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR1, 1, 4_000_000 / 4); + suite.assert_pending_rewards(ADDR2, 1, addr3_pending + 4_000_000 / 4); - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR2, 1); let addr1_pending = 0; let addr3_pending = 0; suite.skip_seconds(10_000); // skips from 460,000 to 470,000, and the end is 462,500, so this allocates only 1_000_000 tokens instead of 4_000_000 - suite.assert_pending_rewards(MEMBER1, 1, addr1_pending + 1_000_000 * 2 / 4); - suite.assert_pending_rewards(MEMBER2, 1, 4_000_000 / 4 + 1_000_000 / 4); - suite.assert_pending_rewards(MEMBER3, 1, addr3_pending + 1_000_000 / 4); + suite.assert_pending_rewards(ADDR0, 1, addr1_pending + 1_000_000 * 2 / 4); + suite.assert_pending_rewards(ADDR1, 1, 4_000_000 / 4 + 1_000_000 / 4); + suite.assert_pending_rewards(ADDR2, 1, addr3_pending + 1_000_000 / 4); - suite.claim_rewards(MEMBER2, 1); + suite.claim_rewards(ADDR1, 1); // TODO: there's a few denoms remaining here, ensure such cases are handled properly let remaining_rewards = @@ -444,80 +444,80 @@ fn test_cw20_dao_native_rewards_block_height_based() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR0, 1, 0); - // MEMBER2 and MEMBER3 unstake their rewards - suite.unstake_cw20_tokens(50, MEMBER2); - suite.unstake_cw20_tokens(50, MEMBER3); + // ADDR1 and ADDR2 unstake their rewards + suite.unstake_cw20_tokens(50, ADDR1); + suite.unstake_cw20_tokens(50, ADDR2); // skip 1/10th of the time suite.skip_blocks(100_000); - // because MEMBER2 and MEMBER3 are not staking, MEMBER1 receives all the rewards. - // MEMBER2 and MEMBER3 should have the same amount of pending rewards as before. - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + // because ADDR1 and ADDR2 are not staking, ADDR0 receives all the rewards. + // ADDR1 and ADDR2 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER2 and MEMBER3 wake up, claim and restake their rewards - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + // ADDR1 and ADDR2 wake up, claim and restake their rewards + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); - suite.stake_cw20_tokens(50, MEMBER2); + suite.stake_cw20_tokens(50, ADDR1); // skip 3/10th of the time suite.skip_blocks(300_000); - suite.stake_cw20_tokens(50, MEMBER3); + suite.stake_cw20_tokens(50, ADDR2); - suite.assert_pending_rewards(MEMBER1, 1, 30_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 0); + suite.assert_pending_rewards(ADDR0, 1, 30_000_000); + suite.assert_pending_rewards(ADDR1, 1, 10_000_000); + suite.assert_pending_rewards(ADDR2, 1, 0); - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 0); + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); let remaining_time = suite.get_time_until_rewards_expiration(); suite.skip_blocks(remaining_time - 100_000); - suite.claim_rewards(MEMBER1, 1); - suite.unstake_cw20_tokens(100, MEMBER1); - suite.assert_pending_rewards(MEMBER1, 1, 0); + suite.claim_rewards(ADDR0, 1); + suite.unstake_cw20_tokens(100, ADDR0); + suite.assert_pending_rewards(ADDR0, 1, 0); suite.skip_blocks(100_000); - suite.unstake_cw20_tokens(50, MEMBER2); + suite.unstake_cw20_tokens(50, ADDR1); suite.skip_blocks(100_000); - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 0); + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); - let addr1_bal = suite.get_balance_native(MEMBER1, GOV_DENOM); - let addr2_bal = suite.get_balance_native(MEMBER2, GOV_DENOM); - let addr3_bal = suite.get_balance_native(MEMBER3, GOV_DENOM); + let addr1_bal = suite.get_balance_native(ADDR0, GOV_DENOM); + let addr2_bal = suite.get_balance_native(ADDR1, GOV_DENOM); + let addr3_bal = suite.get_balance_native(ADDR2, GOV_DENOM); println!("Balances: {}, {}, {}", addr1_bal, addr2_bal, addr3_bal); } @@ -533,41 +533,41 @@ fn test_cw721_dao_rewards() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR0, 1, 0); - // MEMBER2 and MEMBER3 unstake their nfts - suite.unstake_nft(MEMBER2, 3); - suite.unstake_nft(MEMBER3, 4); + // ADDR1 and ADDR2 unstake their nfts + suite.unstake_nft(ADDR1, 3); + suite.unstake_nft(ADDR2, 4); // skip 1/10th of the time suite.skip_blocks(100_000); - // because MEMBER2 and MEMBER3 are not staking, MEMBER1 receives all the rewards. - // MEMBER2 and MEMBER3 should have the same amount of pending rewards as before. - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + // because ADDR1 and ADDR2 are not staking, ADDR0 receives all the rewards. + // ADDR1 and ADDR2 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER2 and MEMBER3 wake up, claim and restake their nfts - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + // ADDR1 and ADDR2 wake up, claim and restake their nfts + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); - suite.stake_nft(MEMBER2, 3); - suite.stake_nft(MEMBER3, 4); + suite.stake_nft(ADDR1, 3); + suite.stake_nft(ADDR2, 4); } #[test] @@ -578,13 +578,13 @@ fn test_claim_zero_rewards() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); - // MEMBER1 attempts to claim again - suite.claim_rewards(MEMBER1, 1); + // ADDR0 attempts to claim again + suite.claim_rewards(ADDR0, 1); } #[test] @@ -610,41 +610,41 @@ fn test_native_dao_cw20_rewards_time_based() { // skip 1/10th of the time suite.skip_seconds(100_000); - // suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + // suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_cw20_balance(cw20_denom, MEMBER1, 10_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_cw20_balance(cw20_denom, ADDR0, 10_000_000); + suite.assert_pending_rewards(ADDR0, 1, 0); - // MEMBER2 and MEMBER3 unstake their stake - suite.unstake_cw20_tokens(50, MEMBER2); - suite.unstake_cw20_tokens(50, MEMBER3); + // ADDR1 and ADDR2 unstake their stake + suite.unstake_cw20_tokens(50, ADDR1); + suite.unstake_cw20_tokens(50, ADDR2); // skip 1/10th of the time suite.skip_seconds(100_000); - // because MEMBER2 and MEMBER3 are not staking, MEMBER1 receives all the rewards. - // MEMBER2 and MEMBER3 should have the same amount of pending rewards as before. - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + // because ADDR1 and ADDR2 are not staking, ADDR0 receives all the rewards. + // ADDR1 and ADDR2 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER2 and MEMBER3 wake up and claim their rewards - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + // ADDR1 and ADDR2 wake up and claim their rewards + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); - suite.assert_cw20_balance(cw20_denom, MEMBER1, 10_000_000); - suite.assert_cw20_balance(cw20_denom, MEMBER2, 5_000_000); + suite.assert_cw20_balance(cw20_denom, ADDR0, 10_000_000); + suite.assert_cw20_balance(cw20_denom, ADDR1, 5_000_000); } #[test] @@ -668,44 +668,44 @@ fn test_native_dao_rewards_time_based() { // skip 1/10th of the time suite.skip_seconds(100_000); - // suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + // suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR0, 1, 0); - // MEMBER2 and MEMBER3 unstake their stake - suite.unstake_native_tokens(MEMBER2, 50); - suite.unstake_native_tokens(MEMBER3, 50); + // ADDR1 and ADDR2 unstake their stake + suite.unstake_native_tokens(ADDR1, 50); + suite.unstake_native_tokens(ADDR2, 50); // skip 1/10th of the time suite.skip_seconds(100_000); - // because MEMBER2 and MEMBER3 are not staking, MEMBER1 receives all the rewards. - // MEMBER2 and MEMBER3 should have the same amount of pending rewards as before. - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + // because ADDR1 and ADDR2 are not staking, ADDR0 receives all the rewards. + // ADDR1 and ADDR2 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER2 and MEMBER3 wake up, claim and restake their rewards - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + // ADDR1 and ADDR2 wake up, claim and restake their rewards + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); - let addr1_balance = suite.get_balance_native(MEMBER1, GOV_DENOM); - let addr2_balance = suite.get_balance_native(MEMBER2, GOV_DENOM); + let addr1_balance = suite.get_balance_native(ADDR0, GOV_DENOM); + let addr2_balance = suite.get_balance_native(ADDR1, GOV_DENOM); - suite.stake_native_tokens(MEMBER1, addr1_balance); - suite.stake_native_tokens(MEMBER2, addr2_balance); + suite.stake_native_tokens(ADDR0, addr1_balance); + suite.stake_native_tokens(ADDR1, addr2_balance); } // all of the `+1` corrections highlight rounding @@ -723,15 +723,15 @@ fn test_native_dao_rewards_time_based_with_rounding() { }) .with_cw4_members(vec![ Member { - addr: MEMBER1.to_string(), + addr: ADDR0.to_string(), weight: 140, }, Member { - addr: MEMBER2.to_string(), + addr: ADDR1.to_string(), weight: 40, }, Member { - addr: MEMBER3.to_string(), + addr: ADDR2.to_string(), weight: 20, }, ]) @@ -744,94 +744,94 @@ fn test_native_dao_rewards_time_based_with_rounding() { // skip 1 interval suite.skip_seconds(100); - suite.assert_pending_rewards(MEMBER1, 1, 70); - suite.assert_pending_rewards(MEMBER2, 1, 20); - suite.assert_pending_rewards(MEMBER3, 1, 10); + suite.assert_pending_rewards(ADDR0, 1, 70); + suite.assert_pending_rewards(ADDR1, 1, 20); + suite.assert_pending_rewards(ADDR2, 1, 10); // change voting power of one of the members and claim suite.update_members( vec![Member { - addr: MEMBER2.to_string(), + addr: ADDR1.to_string(), weight: 60, }], vec![], ); - suite.claim_rewards(MEMBER2, 1); - suite.assert_native_balance(MEMBER2, GOV_DENOM, 20); - suite.assert_pending_rewards(MEMBER2, 1, 0); + suite.claim_rewards(ADDR1, 1); + suite.assert_native_balance(ADDR1, GOV_DENOM, 20); + suite.assert_pending_rewards(ADDR1, 1, 0); // skip 1 interval suite.skip_seconds(100); - suite.assert_pending_rewards(MEMBER1, 1, 70 + 63); - suite.assert_pending_rewards(MEMBER2, 1, 27); - suite.assert_pending_rewards(MEMBER3, 1, 10 + 9); + suite.assert_pending_rewards(ADDR0, 1, 70 + 63); + suite.assert_pending_rewards(ADDR1, 1, 27); + suite.assert_pending_rewards(ADDR2, 1, 10 + 9); // increase reward rate and claim suite.update_emission_rate(1, Duration::Time(100), 150, true); - suite.claim_rewards(MEMBER3, 1); - suite.assert_native_balance(MEMBER3, GOV_DENOM, 10 + 9); - suite.assert_pending_rewards(MEMBER3, 1, 0); + suite.claim_rewards(ADDR2, 1); + suite.assert_native_balance(ADDR2, GOV_DENOM, 10 + 9); + suite.assert_pending_rewards(ADDR2, 1, 0); // skip 1 interval suite.skip_seconds(100); - suite.assert_pending_rewards(MEMBER1, 1, 70 + 63 + 95 + 1); - suite.assert_pending_rewards(MEMBER2, 1, 27 + 40 + 1); - suite.assert_pending_rewards(MEMBER3, 1, 13); + suite.assert_pending_rewards(ADDR0, 1, 70 + 63 + 95 + 1); + suite.assert_pending_rewards(ADDR1, 1, 27 + 40 + 1); + suite.assert_pending_rewards(ADDR2, 1, 13); // claim rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 70 + 63 + 95 + 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); + suite.claim_rewards(ADDR0, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 70 + 63 + 95 + 1); + suite.assert_pending_rewards(ADDR0, 1, 0); // skip 3 intervals suite.skip_seconds(300); - suite.assert_pending_rewards(MEMBER1, 1, 3 * 95 + 1); - suite.assert_pending_rewards(MEMBER2, 1, 27 + 4 * 40 + 1 + 1 + 1); - suite.assert_pending_rewards(MEMBER3, 1, 4 * 13 + 1 + 1); + suite.assert_pending_rewards(ADDR0, 1, 3 * 95 + 1); + suite.assert_pending_rewards(ADDR1, 1, 27 + 4 * 40 + 1 + 1 + 1); + suite.assert_pending_rewards(ADDR2, 1, 4 * 13 + 1 + 1); // change voting power for all suite.update_members( vec![ Member { - addr: MEMBER1.to_string(), + addr: ADDR0.to_string(), weight: 100, }, Member { - addr: MEMBER2.to_string(), + addr: ADDR1.to_string(), weight: 80, }, Member { - addr: MEMBER3.to_string(), + addr: ADDR2.to_string(), weight: 40, }, ], vec![], ); - suite.claim_rewards(MEMBER2, 1); - suite.assert_native_balance(MEMBER2, GOV_DENOM, 20 + 27 + 4 * 40 + 1 + 1 + 1); - suite.assert_pending_rewards(MEMBER2, 1, 0); + suite.claim_rewards(ADDR1, 1); + suite.assert_native_balance(ADDR1, GOV_DENOM, 20 + 27 + 4 * 40 + 1 + 1 + 1); + suite.assert_pending_rewards(ADDR1, 1, 0); // skip 1 interval suite.skip_seconds(100); - suite.assert_pending_rewards(MEMBER1, 1, 3 * 95 + 1 + 68); - suite.assert_pending_rewards(MEMBER2, 1, 54); - suite.assert_pending_rewards(MEMBER3, 1, 4 * 13 + 1 + 1 + 27); + suite.assert_pending_rewards(ADDR0, 1, 3 * 95 + 1 + 68); + suite.assert_pending_rewards(ADDR1, 1, 54); + suite.assert_pending_rewards(ADDR2, 1, 4 * 13 + 1 + 1 + 27); // claim all - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 70 + 63 + 95 + 1 + 3 * 95 + 1 + 68); - suite.assert_native_balance(MEMBER2, GOV_DENOM, 20 + 27 + 4 * 40 + 1 + 1 + 1 + 54); - suite.assert_native_balance(MEMBER3, GOV_DENOM, 10 + 9 + 4 * 13 + 1 + 1 + 27); - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 0); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 70 + 63 + 95 + 1 + 3 * 95 + 1 + 68); + suite.assert_native_balance(ADDR1, GOV_DENOM, 20 + 27 + 4 * 40 + 1 + 1 + 1 + 54); + suite.assert_native_balance(ADDR2, GOV_DENOM, 10 + 9 + 4 * 13 + 1 + 1 + 27); + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); // TODO: fix this rug of 3 udenom by the distribution contract suite.assert_native_balance( @@ -875,9 +875,9 @@ fn test_immediate_emission() { .unwrap(); // users immediately have access to rewards - suite.assert_pending_rewards(MEMBER1, 2, 50_000_000); - suite.assert_pending_rewards(MEMBER2, 2, 25_000_000); - suite.assert_pending_rewards(MEMBER3, 2, 25_000_000); + suite.assert_pending_rewards(ADDR0, 2, 50_000_000); + suite.assert_pending_rewards(ADDR1, 2, 25_000_000); + suite.assert_pending_rewards(ADDR2, 2, 25_000_000); // ensure undistributed rewards are immediately 0 suite.assert_undistributed_rewards(2, 0); @@ -886,16 +886,16 @@ fn test_immediate_emission() { suite.fund_native(2, coin(100_000_000, ALT_DENOM)); // users immediately have access to new rewards - suite.assert_pending_rewards(MEMBER1, 2, 2 * 50_000_000); - suite.assert_pending_rewards(MEMBER2, 2, 2 * 25_000_000); - suite.assert_pending_rewards(MEMBER3, 2, 2 * 25_000_000); + suite.assert_pending_rewards(ADDR0, 2, 2 * 50_000_000); + suite.assert_pending_rewards(ADDR1, 2, 2 * 25_000_000); + suite.assert_pending_rewards(ADDR2, 2, 2 * 25_000_000); // ensure undistributed rewards are immediately 0 suite.assert_undistributed_rewards(2, 0); // a new user stakes tokens - suite.mint_native(coin(200, GOV_DENOM), MEMBER4); - suite.stake_native_tokens(MEMBER4, 200); + suite.mint_native(coin(200, GOV_DENOM), ADDR3); + suite.stake_native_tokens(ADDR3, 200); // skip 2 blocks so stake takes effect suite.skip_blocks(2); @@ -903,22 +903,22 @@ fn test_immediate_emission() { // another fund takes into account new voting power suite.fund_native(2, coin(100_000_000, ALT_DENOM)); - suite.assert_pending_rewards(MEMBER1, 2, 2 * 50_000_000 + 25_000_000); - suite.assert_pending_rewards(MEMBER2, 2, 2 * 25_000_000 + 12_500_000); - suite.assert_pending_rewards(MEMBER3, 2, 2 * 25_000_000 + 12_500_000); - suite.assert_pending_rewards(MEMBER4, 2, 50_000_000); + suite.assert_pending_rewards(ADDR0, 2, 2 * 50_000_000 + 25_000_000); + suite.assert_pending_rewards(ADDR1, 2, 2 * 25_000_000 + 12_500_000); + suite.assert_pending_rewards(ADDR2, 2, 2 * 25_000_000 + 12_500_000); + suite.assert_pending_rewards(ADDR3, 2, 50_000_000); // ensure undistributed rewards are immediately 0 suite.assert_undistributed_rewards(2, 0); - suite.claim_rewards(MEMBER1, 2); - suite.claim_rewards(MEMBER2, 2); - suite.claim_rewards(MEMBER3, 2); - suite.claim_rewards(MEMBER4, 2); + suite.claim_rewards(ADDR0, 2); + suite.claim_rewards(ADDR1, 2); + suite.claim_rewards(ADDR2, 2); + suite.claim_rewards(ADDR3, 2); - suite.unstake_native_tokens(MEMBER1, 100); - suite.unstake_native_tokens(MEMBER2, 50); - suite.unstake_native_tokens(MEMBER3, 50); + suite.unstake_native_tokens(ADDR0, 100); + suite.unstake_native_tokens(ADDR1, 50); + suite.unstake_native_tokens(ADDR2, 50); // skip 2 blocks so stake takes effect suite.skip_blocks(2); @@ -926,10 +926,10 @@ fn test_immediate_emission() { // another fund takes into account new voting power suite.fund_native(2, coin(100_000_000, ALT_DENOM)); - suite.assert_pending_rewards(MEMBER1, 2, 0); - suite.assert_pending_rewards(MEMBER2, 2, 0); - suite.assert_pending_rewards(MEMBER3, 2, 0); - suite.assert_pending_rewards(MEMBER4, 2, 100_000_000); + suite.assert_pending_rewards(ADDR0, 2, 0); + suite.assert_pending_rewards(ADDR1, 2, 0); + suite.assert_pending_rewards(ADDR2, 2, 0); + suite.assert_pending_rewards(ADDR3, 2, 100_000_000); // ensure undistributed rewards are immediately 0 suite.assert_undistributed_rewards(2, 0); @@ -943,9 +943,9 @@ fn test_immediate_emission_fails_if_no_voting_power() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); // all users unstake - suite.unstake_native_tokens(MEMBER1, 100); - suite.unstake_native_tokens(MEMBER2, 50); - suite.unstake_native_tokens(MEMBER3, 50); + suite.unstake_native_tokens(ADDR0, 100); + suite.unstake_native_tokens(ADDR1, 50); + suite.unstake_native_tokens(ADDR2, 50); // skip 2 blocks since the contract depends on the previous block's total // voting power, and voting power takes 1 block to take effect. so if voting @@ -988,52 +988,52 @@ fn test_transition_to_immediate() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR0, 1, 0); - // MEMBER2 unstakes their stake - suite.unstake_native_tokens(MEMBER2, 50); + // ADDR1 unstakes their stake + suite.unstake_native_tokens(ADDR1, 50); // skip 1/10th of the time suite.skip_blocks(100_000); - // because MEMBER2 is not staking, MEMBER1 and MEMBER3 receive the rewards. MEMBER2 + // because ADDR1 is not staking, ADDR0 and ADDR2 receive the rewards. ADDR1 // should have the same amount of pending rewards as before. - suite.assert_pending_rewards(MEMBER1, 1, 6_666_666); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000 + 3_333_333); + suite.assert_pending_rewards(ADDR0, 1, 6_666_666); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000 + 3_333_333); - // MEMBER2 claims their rewards - suite.claim_rewards(MEMBER2, 1); - suite.assert_pending_rewards(MEMBER2, 1, 0); + // ADDR1 claims their rewards + suite.claim_rewards(ADDR1, 1); + suite.assert_pending_rewards(ADDR1, 1, 0); // switching to immediate emission instantly distributes the remaining 70M suite.set_immediate_emission(1); - // MEMBER1 and MEMBER3 split the rewards, and MEMBER2 gets none - suite.assert_pending_rewards(MEMBER1, 1, 6_666_666 + 46_666_666 + 1); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000 + 3_333_333 + 23_333_333); + // ADDR0 and ADDR2 split the rewards, and ADDR1 gets none + suite.assert_pending_rewards(ADDR0, 1, 6_666_666 + 46_666_666 + 1); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000 + 3_333_333 + 23_333_333); // claim all rewards - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR2, 1); - // MEMBER3 unstakes their stake, leaving only MEMBER1 staked - suite.unstake_native_tokens(MEMBER3, 50); + // ADDR2 unstakes their stake, leaving only ADDR0 staked + suite.unstake_native_tokens(ADDR2, 50); // skip 2 blocks so unstake takes effect suite.skip_blocks(2); @@ -1042,8 +1042,8 @@ fn test_transition_to_immediate() { suite.mint_native(coin(100_000_000, GOV_DENOM), OWNER); suite.fund_native(1, coin(100_000_000, GOV_DENOM)); - // MEMBER1 gets all - suite.assert_pending_rewards(MEMBER1, 1, 100_000_000); + // ADDR0 gets all + suite.assert_pending_rewards(ADDR0, 1, 100_000_000); // change back to linear emission suite.update_emission_rate(1, Duration::Height(10), 1000, true); @@ -1052,14 +1052,14 @@ fn test_transition_to_immediate() { suite.mint_native(coin(100_000_000, GOV_DENOM), OWNER); suite.fund_native(1, coin(100_000_000, GOV_DENOM)); - // MEMBER1 has same pending as before - suite.assert_pending_rewards(MEMBER1, 1, 100_000_000); + // ADDR0 has same pending as before + suite.assert_pending_rewards(ADDR0, 1, 100_000_000); // skip 1/10th of the time suite.skip_blocks(100_000); - // MEMBER1 has new linearly distributed rewards - suite.assert_pending_rewards(MEMBER1, 1, 100_000_000 + 10_000_000); + // ADDR0 has new linearly distributed rewards + suite.assert_pending_rewards(ADDR0, 1, 100_000_000 + 10_000_000); } #[test] @@ -1073,44 +1073,44 @@ fn test_native_dao_rewards() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 10_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR0, 1, 0); - // MEMBER2 and MEMBER3 unstake their stake - suite.unstake_native_tokens(MEMBER2, 50); - suite.unstake_native_tokens(MEMBER3, 50); + // ADDR1 and ADDR2 unstake their stake + suite.unstake_native_tokens(ADDR1, 50); + suite.unstake_native_tokens(ADDR2, 50); // skip 1/10th of the time suite.skip_blocks(100_000); - // because MEMBER2 and MEMBER3 are not staking, MEMBER1 receives all the rewards. - // MEMBER2 and MEMBER3 should have the same amount of pending rewards as before. - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 5_000_000); + // because ADDR1 and ADDR2 are not staking, ADDR0 receives all the rewards. + // ADDR1 and ADDR2 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 5_000_000); + suite.assert_pending_rewards(ADDR2, 1, 5_000_000); - // MEMBER2 and MEMBER3 wake up, claim and restake their rewards - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + // ADDR1 and ADDR2 wake up, claim and restake their rewards + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); - let addr1_balance = suite.get_balance_native(MEMBER1, GOV_DENOM); - let addr2_balance = suite.get_balance_native(MEMBER2, GOV_DENOM); + let addr1_balance = suite.get_balance_native(ADDR0, GOV_DENOM); + let addr2_balance = suite.get_balance_native(ADDR1, GOV_DENOM); - suite.stake_native_tokens(MEMBER1, addr1_balance); - suite.stake_native_tokens(MEMBER2, addr2_balance); + suite.stake_native_tokens(ADDR0, addr1_balance); + suite.stake_native_tokens(ADDR1, addr2_balance); } #[test] @@ -1124,27 +1124,27 @@ fn test_continuous_backfill_latest_voting_power() { // skip all of the time suite.skip_blocks(1_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 50_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 25_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 25_000_000); + suite.assert_pending_rewards(ADDR0, 1, 50_000_000); + suite.assert_pending_rewards(ADDR1, 1, 25_000_000); + suite.assert_pending_rewards(ADDR2, 1, 25_000_000); - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); // skip 1/10th of the time suite.skip_blocks(100_000); // change voting powers (1 = 200, 2 = 50, 3 = 50) - suite.stake_native_tokens(MEMBER1, 100); + suite.stake_native_tokens(ADDR0, 100); // skip 1/10th of the time suite.skip_blocks(100_000); // change voting powers again (1 = 50, 2 = 100, 3 = 100) - suite.unstake_native_tokens(MEMBER1, 150); - suite.stake_native_tokens(MEMBER2, 50); - suite.stake_native_tokens(MEMBER3, 50); + suite.unstake_native_tokens(ADDR0, 150); + suite.stake_native_tokens(ADDR1, 50); + suite.stake_native_tokens(ADDR2, 50); // skip 1/10th of the time suite.skip_blocks(100_000); @@ -1154,9 +1154,9 @@ fn test_continuous_backfill_latest_voting_power() { // since this is continuous, rewards should backfill based on the latest // voting powers. we skipped 30% of the time, so 30M should be distributed - suite.assert_pending_rewards(MEMBER1, 1, 6_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 12_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 12_000_000); + suite.assert_pending_rewards(ADDR0, 1, 6_000_000); + suite.assert_pending_rewards(ADDR1, 1, 12_000_000); + suite.assert_pending_rewards(ADDR2, 1, 12_000_000); } #[test] @@ -1170,123 +1170,119 @@ fn test_cw4_dao_rewards() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // remove the second member - suite.update_members(vec![], vec![MEMBER2.to_string()]); + suite.update_members(vec![], vec![ADDR1.to_string()]); suite.query_members(); // skip 1/10th of the time suite.skip_blocks(100_000); - // now that MEMBER2 is no longer a member, MEMBER1 and MEMBER3 will split the rewards - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000 + 6_666_666); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 3_333_333 + 2_500_000); + // now that ADDR1 is no longer a member, ADDR0 and ADDR2 will split the rewards + suite.assert_pending_rewards(ADDR0, 1, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 3_333_333 + 2_500_000); // reintroduce the 2nd member with double the vp let add_member_2 = Member { - addr: MEMBER2.to_string(), + addr: ADDR1.to_string(), weight: 2, }; suite.update_members(vec![add_member_2], vec![]); suite.query_members(); - // now the vp split is [MEMBER1: 40%, MEMBER2: 40%, MEMBER3: 20%] + // now the vp split is [ADDR0: 40%, ADDR1: 40%, ADDR2: 20%] // meaning the token reward per 100k blocks is 4mil, 4mil, 2mil - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 5_000_000 + 6_666_666); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 5_000_000 + 6_666_666); - // assert pending rewards are still the same (other than MEMBER1) - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 3_333_333 + 2_500_000); + // assert pending rewards are still the same (other than ADDR0) + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 3_333_333 + 2_500_000); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 4_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 6_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 7_833_333); + suite.assert_pending_rewards(ADDR0, 1, 4_000_000); + suite.assert_pending_rewards(ADDR1, 1, 6_500_000); + suite.assert_pending_rewards(ADDR2, 1, 7_833_333); // skip 1/2 of time, leaving 200k blocks left suite.skip_blocks(500_000); - suite.assert_pending_rewards(MEMBER1, 1, 24_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 26_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 17_833_333); + suite.assert_pending_rewards(ADDR0, 1, 24_000_000); + suite.assert_pending_rewards(ADDR1, 1, 26_500_000); + suite.assert_pending_rewards(ADDR2, 1, 17_833_333); // remove all members suite.update_members( vec![], - vec![ - MEMBER1.to_string(), - MEMBER2.to_string(), - MEMBER3.to_string(), - ], + vec![ADDR0.to_string(), ADDR1.to_string(), ADDR2.to_string()], ); - suite.assert_pending_rewards(MEMBER1, 1, 24_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 26_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 17_833_333); + suite.assert_pending_rewards(ADDR0, 1, 24_000_000); + suite.assert_pending_rewards(ADDR1, 1, 26_500_000); + suite.assert_pending_rewards(ADDR2, 1, 17_833_333); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 24_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 26_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 17_833_333); + suite.assert_pending_rewards(ADDR0, 1, 24_000_000); + suite.assert_pending_rewards(ADDR1, 1, 26_500_000); + suite.assert_pending_rewards(ADDR2, 1, 17_833_333); suite.update_members( vec![ Member { - addr: MEMBER1.to_string(), + addr: ADDR0.to_string(), weight: 2, }, Member { - addr: MEMBER2.to_string(), + addr: ADDR1.to_string(), weight: 2, }, Member { - addr: MEMBER3.to_string(), + addr: ADDR2.to_string(), weight: 1, }, ], vec![], ); - suite.assert_pending_rewards(MEMBER1, 1, 24_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 26_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 17_833_333); + suite.assert_pending_rewards(ADDR0, 1, 24_000_000); + suite.assert_pending_rewards(ADDR1, 1, 26_500_000); + suite.assert_pending_rewards(ADDR2, 1, 17_833_333); - suite.claim_rewards(MEMBER1, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 35_666_666); + suite.claim_rewards(ADDR0, 1); + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_native_balance(ADDR0, GOV_DENOM, 35_666_666); // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 4_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 30_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 19_833_333); + suite.assert_pending_rewards(ADDR0, 1, 4_000_000); + suite.assert_pending_rewards(ADDR1, 1, 30_500_000); + suite.assert_pending_rewards(ADDR2, 1, 19_833_333); // at the very expiration block, claim rewards - suite.claim_rewards(MEMBER2, 1); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_native_balance(MEMBER2, GOV_DENOM, 30_500_000); + suite.claim_rewards(ADDR1, 1); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_native_balance(ADDR1, GOV_DENOM, 30_500_000); suite.skip_blocks(100_000); - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR2, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 0); + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 0); let contract = suite.distribution_contract.clone(); @@ -1365,7 +1361,7 @@ fn test_fund_cw20_with_invalid_cw20_receive_msg() { let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); let unregistered_cw20_coin = Cw20Coin { - address: MEMBER1.to_string(), + address: ADDR0.to_string(), amount: Uint128::new(1_000_000), }; @@ -1376,7 +1372,7 @@ fn test_fund_cw20_with_invalid_cw20_receive_msg() { .base .app .execute_contract( - Addr::unchecked(MEMBER1), + Addr::unchecked(ADDR0), new_cw20_mint.clone(), &cw20::Cw20ExecuteMsg::Send { contract: suite.distribution_contract.to_string(), @@ -1395,7 +1391,7 @@ fn test_fund_invalid_cw20_denom() { let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); let unregistered_cw20_coin = Cw20Coin { - address: MEMBER1.to_string(), + address: ADDR0.to_string(), amount: Uint128::new(1_000_000), }; @@ -1423,16 +1419,16 @@ fn test_withdraw_alternative_destination_address() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // user 1 and 2 claim their rewards - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); // user 2 unstakes - suite.unstake_native_tokens(MEMBER2, 50); + suite.unstake_native_tokens(ADDR1, 50); suite.skip_blocks(100_000); @@ -1463,16 +1459,16 @@ fn test_withdraw_block_based() { // skip 1/10th of the time suite.skip_blocks(100_000); - // suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - // suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - // suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + // suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + // suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + // suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // user 1 and 2 claim their rewards - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); // user 2 unstakes - suite.unstake_native_tokens(MEMBER2, 50); + suite.unstake_native_tokens(ADDR1, 50); suite.skip_blocks(100_000); @@ -1508,22 +1504,22 @@ fn test_withdraw_block_based() { ); // we assert that pending rewards did not change - suite.assert_pending_rewards(MEMBER1, 1, 6_666_666); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 3_333_333 + 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 6_666_666); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 3_333_333 + 2_500_000); // user 1 can claim their rewards - suite.claim_rewards(MEMBER1, 1); - // suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 11_666_666); + suite.claim_rewards(ADDR0, 1); + // suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_native_balance(ADDR0, GOV_DENOM, 11_666_666); // user 3 can unstake and claim their rewards - suite.unstake_native_tokens(MEMBER3, 50); + suite.unstake_native_tokens(ADDR2, 50); suite.skip_blocks(100_000); - suite.assert_native_balance(MEMBER3, GOV_DENOM, 50); - suite.claim_rewards(MEMBER3, 1); - // suite.assert_pending_rewards(MEMBER3, 1, 0); - suite.assert_native_balance(MEMBER3, GOV_DENOM, 3_333_333 + 2_500_000 + 50); + suite.assert_native_balance(ADDR2, GOV_DENOM, 50); + suite.claim_rewards(ADDR2, 1); + // suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_native_balance(ADDR2, GOV_DENOM, 3_333_333 + 2_500_000 + 50); // TODO: fix this rug of 1 udenom by the distribution contract suite.assert_native_balance(&distribution_contract, GOV_DENOM, 1); @@ -1544,16 +1540,16 @@ fn test_withdraw_time_based() { // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // user 1 and 2 claim their rewards - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); // user 2 unstakes - suite.unstake_native_tokens(MEMBER2, 50); + suite.unstake_native_tokens(ADDR1, 50); suite.skip_seconds(100_000); @@ -1589,22 +1585,22 @@ fn test_withdraw_time_based() { ); // we assert that pending rewards did not change - suite.assert_pending_rewards(MEMBER1, 1, 6_666_666); - suite.assert_pending_rewards(MEMBER2, 1, 0); - suite.assert_pending_rewards(MEMBER3, 1, 3_333_333 + 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 6_666_666); + suite.assert_pending_rewards(ADDR1, 1, 0); + suite.assert_pending_rewards(ADDR2, 1, 3_333_333 + 2_500_000); // user 1 can claim their rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_pending_rewards(MEMBER1, 1, 0); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 11_666_666); + suite.claim_rewards(ADDR0, 1); + suite.assert_pending_rewards(ADDR0, 1, 0); + suite.assert_native_balance(ADDR0, GOV_DENOM, 11_666_666); // user 3 can unstake and claim their rewards - suite.unstake_native_tokens(MEMBER3, 50); + suite.unstake_native_tokens(ADDR2, 50); suite.skip_seconds(100_000); - suite.assert_native_balance(MEMBER3, GOV_DENOM, 50); - suite.claim_rewards(MEMBER3, 1); - suite.assert_pending_rewards(MEMBER3, 1, 0); - suite.assert_native_balance(MEMBER3, GOV_DENOM, 3_333_333 + 2_500_000 + 50); + suite.assert_native_balance(ADDR2, GOV_DENOM, 50); + suite.claim_rewards(ADDR2, 1); + suite.assert_pending_rewards(ADDR2, 1, 0); + suite.assert_native_balance(ADDR2, GOV_DENOM, 3_333_333 + 2_500_000 + 50); // TODO: fix this rug of 1 udenom by the distribution contract suite.assert_native_balance(&distribution_contract, GOV_DENOM, 1); @@ -1625,14 +1621,14 @@ fn test_withdraw_and_restart_with_continuous() { // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // users claim their rewards - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); // skip 1/10th of the time suite.skip_seconds(100_000); @@ -1670,12 +1666,12 @@ fn test_withdraw_and_restart_with_continuous() { ); // we assert that pending rewards did not change - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); // fund again suite.fund_native(1, coin(100_000_000, GOV_DENOM)); @@ -1683,9 +1679,9 @@ fn test_withdraw_and_restart_with_continuous() { // check that pending rewards did not restart. since we skipped 1/10th the // time after the withdraw occurred, everyone should already have 10% of the // new amount pending. - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); } #[test] @@ -1703,14 +1699,14 @@ fn test_withdraw_and_restart_not_continuous() { // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // users claim their rewards - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); // skip 1/10th of the time suite.skip_seconds(100_000); @@ -1748,12 +1744,12 @@ fn test_withdraw_and_restart_not_continuous() { ); // we assert that pending rewards did not change - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); // fund again suite.fund_native(1, coin(100_000_000, GOV_DENOM)); @@ -1764,9 +1760,9 @@ fn test_withdraw_and_restart_not_continuous() { // check that pending rewards restarted from the funding date. since we // skipped 1/10th the time after the funding occurred, everyone should // have 10% of the new amount pending - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); } #[test] @@ -1781,7 +1777,7 @@ fn test_withdraw_unauthorized() { .base .app .execute_contract( - Addr::unchecked(MEMBER1), + Addr::unchecked(ADDR0), suite.distribution_contract.clone(), &ExecuteMsg::Withdraw { id: 1 }, &[], @@ -1806,7 +1802,7 @@ fn test_claim_404() { suite.skip_blocks(100_000); - suite.claim_rewards(MEMBER1, 3); + suite.claim_rewards(ADDR0, 3); } #[test] @@ -1904,22 +1900,22 @@ fn test_fund_native_block_based_post_expiration_not_continuous() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); - // MEMBER2 unstake their stake - suite.unstake_native_tokens(MEMBER2, 50); + // ADDR1 unstake their stake + suite.unstake_native_tokens(ADDR1, 50); // addr3 claims their rewards - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR2, 1); // skip to 100_000 blocks past the expiration suite.skip_blocks(1_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 65_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 30_000_000); + suite.assert_pending_rewards(ADDR0, 1, 65_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 30_000_000); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -1966,23 +1962,23 @@ fn test_fund_cw20_time_based_post_expiration_not_continuous() { // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); - // MEMBER2 unstake their stake - suite.unstake_cw20_tokens(50, MEMBER2); + // ADDR1 unstake their stake + suite.unstake_cw20_tokens(50, ADDR1); // addr3 claims their rewards - suite.claim_rewards(MEMBER3, 1); - suite.assert_cw20_balance(cw20_denom, MEMBER3, 2_500_000); + suite.claim_rewards(ADDR2, 1); + suite.assert_cw20_balance(cw20_denom, ADDR2, 2_500_000); // skip to 100_000 blocks past the expiration suite.skip_seconds(1_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 65_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 30_000_000); + suite.assert_pending_rewards(ADDR0, 1, 65_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 30_000_000); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -2034,22 +2030,22 @@ fn test_fund_cw20_time_based_pre_expiration() { // skip 1/10th of the time suite.skip_seconds(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); - // MEMBER2 unstake their stake - suite.unstake_cw20_tokens(50, MEMBER2); + // ADDR1 unstake their stake + suite.unstake_cw20_tokens(50, ADDR1); // addr3 claims their rewards - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR2, 1); // skip to 100_000 blocks before the expiration suite.skip_seconds(800_000); - suite.assert_pending_rewards(MEMBER1, 1, 58_333_333); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 26_666_666); + suite.assert_pending_rewards(ADDR0, 1, 58_333_333); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 26_666_666); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -2090,22 +2086,22 @@ fn test_fund_native_height_based_pre_expiration() { // skip 1/10th of the time suite.skip_blocks(100_000); - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); - // MEMBER2 unstake their stake - suite.unstake_native_tokens(MEMBER2, 50); + // ADDR1 unstake their stake + suite.unstake_native_tokens(ADDR1, 50); // addr3 claims their rewards - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR2, 1); // skip to 100_000 blocks before the expiration suite.skip_blocks(800_000); - suite.assert_pending_rewards(MEMBER1, 1, 58_333_333); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 26_666_666); + suite.assert_pending_rewards(ADDR0, 1, 58_333_333); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 26_666_666); suite.assert_ends_at(expiration_date); suite.assert_started_at(started_at); @@ -2130,7 +2126,7 @@ fn test_native_dao_rewards_entry_edge_case() { let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); // we start with the following staking power split: - // [MEMBER1: 100, MEMBER2: 50, MEMBER3: 50], or [MEMBER1: 50%, MEMBER2: 25%, MEMBER3: 25% + // [ADDR0: 100, ADDR1: 50, ADDR2: 50], or [ADDR0: 50%, ADDR1: 25%, ADDR2: 25% suite.assert_amount(1_000); suite.assert_ends_at(Expiration::AtHeight(1_000_000)); suite.assert_duration(10); @@ -2138,63 +2134,63 @@ fn test_native_dao_rewards_entry_edge_case() { // skip 1/10th of the time suite.skip_blocks(100_000); - // MEMBER1 stakes additional 100 tokens, bringing the new staking power split to - // [MEMBER1: 200, MEMBER2: 50, MEMBER3: 50], or [MEMBER1: 66.6%, MEMBER2: 16.6%, MEMBER3: 16.6%] - // this means that per 100_000 blocks, MEMBER1 should receive 6_666_666, while - // MEMBER2 and MEMBER3 should receive 1_666_666 each. - suite.mint_native(coin(100, GOV_DENOM), MEMBER1); - suite.stake_native_tokens(MEMBER1, 100); + // ADDR0 stakes additional 100 tokens, bringing the new staking power split to + // [ADDR0: 200, ADDR1: 50, ADDR2: 50], or [ADDR0: 66.6%, ADDR1: 16.6%, ADDR2: 16.6%] + // this means that per 100_000 blocks, ADDR0 should receive 6_666_666, while + // ADDR1 and ADDR2 should receive 1_666_666 each. + suite.mint_native(coin(100, GOV_DENOM), ADDR0); + suite.stake_native_tokens(ADDR0, 100); // rewards here should not be affected by the new stake, - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip 1/10th of the time suite.skip_blocks(100_000); // here we should see the new stake affecting the rewards split. - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000 + 6_666_666); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000 + 1_666_666); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000 + 1_666_666); - // MEMBER1 claims rewards - suite.claim_rewards(MEMBER1, 1); - suite.assert_native_balance(MEMBER1, GOV_DENOM, 5_000_000 + 6_666_666); - suite.assert_pending_rewards(MEMBER1, 1, 0); + // ADDR0 claims rewards + suite.claim_rewards(ADDR0, 1); + suite.assert_native_balance(ADDR0, GOV_DENOM, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR0, 1, 0); - // MEMBER2 and MEMBER3 unstake their stake - // new voting power split is [MEMBER1: 100%, MEMBER2: 0%, MEMBER3: 0%] - suite.unstake_native_tokens(MEMBER2, 50); - suite.unstake_native_tokens(MEMBER3, 50); + // ADDR1 and ADDR2 unstake their stake + // new voting power split is [ADDR0: 100%, ADDR1: 0%, ADDR2: 0%] + suite.unstake_native_tokens(ADDR1, 50); + suite.unstake_native_tokens(ADDR2, 50); - // we assert that by unstaking, MEMBER2 and MEMBER3 do not forfeit their earned but unclaimed rewards - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000 + 1_666_666); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000 + 1_666_666); + // we assert that by unstaking, ADDR1 and ADDR2 do not forfeit their earned but unclaimed rewards + suite.assert_pending_rewards(ADDR1, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000 + 1_666_666); // skip a block and assert that nothing changes suite.skip_blocks(1); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000 + 1_666_666); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000 + 1_666_666); // skip the remaining blocks to reach 1/10th of the time suite.skip_blocks(99_999); - // because MEMBER2 and MEMBER3 are not staking, MEMBER1 receives all the rewards. - // MEMBER2 and MEMBER3 should have the same amount of pending rewards as before. - suite.assert_pending_rewards(MEMBER1, 1, 10_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000 + 1_666_666); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000 + 1_666_666); + // because ADDR1 and ADDR2 are not staking, ADDR0 receives all the rewards. + // ADDR1 and ADDR2 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR0, 1, 10_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000 + 1_666_666); - // MEMBER2 and MEMBER3 wake up, claim and restake their rewards - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + // ADDR1 and ADDR2 wake up, claim and restake their rewards + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); - let addr1_balance = suite.get_balance_native(MEMBER1, GOV_DENOM); - let addr2_balance = suite.get_balance_native(MEMBER2, GOV_DENOM); + let addr1_balance = suite.get_balance_native(ADDR0, GOV_DENOM); + let addr2_balance = suite.get_balance_native(ADDR1, GOV_DENOM); - suite.stake_native_tokens(MEMBER1, addr1_balance); - suite.stake_native_tokens(MEMBER2, addr2_balance); + suite.stake_native_tokens(ADDR0, addr1_balance); + suite.stake_native_tokens(ADDR1, addr2_balance); } #[test] @@ -2236,9 +2232,9 @@ fn test_fund_native_on_create() { suite.skip_blocks(1_000_000); // skip 1/10th of the time - suite.assert_pending_rewards(MEMBER1, 2, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 2, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 2, 2_500_000); + suite.assert_pending_rewards(ADDR0, 2, 5_000_000); + suite.assert_pending_rewards(ADDR1, 2, 2_500_000); + suite.assert_pending_rewards(ADDR2, 2, 2_500_000); } #[test] @@ -2483,17 +2479,17 @@ fn test_rewards_not_lost_after_discontinuous_restart() { suite.skip_blocks(33_333); // check pending rewards - suite.assert_pending_rewards(MEMBER1, 1, 49999500); - suite.assert_pending_rewards(MEMBER2, 1, 24999750); - suite.assert_pending_rewards(MEMBER3, 1, 24999750); + suite.assert_pending_rewards(ADDR0, 1, 49999500); + suite.assert_pending_rewards(ADDR1, 1, 24999750); + suite.assert_pending_rewards(ADDR2, 1, 24999750); // before user claim rewards, someone funded suite.fund_native(1, coin(1u128, GOV_DENOM)); // pending rewards should still exist - suite.assert_pending_rewards(MEMBER1, 1, 49999500); - suite.assert_pending_rewards(MEMBER2, 1, 24999750); - suite.assert_pending_rewards(MEMBER3, 1, 24999750); + suite.assert_pending_rewards(ADDR0, 1, 49999500); + suite.assert_pending_rewards(ADDR1, 1, 24999750); + suite.assert_pending_rewards(ADDR2, 1, 24999750); } #[test] @@ -2508,17 +2504,17 @@ fn test_fund_while_paused() { suite.skip_blocks(100_000); // check pending rewards - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // pause suite.pause_emission(1); // pending rewards should still exist - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // fund during pause the amount that's already been distributed suite.fund_native(1, coin(10_000_000, GOV_DENOM)); @@ -2533,9 +2529,9 @@ fn test_fund_while_paused() { suite.skip_blocks(100_000); // check pending rewards - suite.assert_pending_rewards(MEMBER1, 1, 2 * 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2 * 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2 * 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 2 * 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2 * 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2 * 2_500_000); // pause and fund more suite.pause_emission(1); @@ -2561,9 +2557,9 @@ fn test_pause_expired() { suite.skip_blocks(100_000); // check pending rewards - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // check undistributed rewards suite.assert_undistributed_rewards(1, 90_000_000); @@ -2578,9 +2574,9 @@ fn test_pause_expired() { suite.update_emission_rate(1, Duration::Height(10), 1_000, false); // check pending rewards are the same - suite.assert_pending_rewards(MEMBER1, 1, 5_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 2_500_000); - suite.assert_pending_rewards(MEMBER3, 1, 2_500_000); + suite.assert_pending_rewards(ADDR0, 1, 5_000_000); + suite.assert_pending_rewards(ADDR1, 1, 2_500_000); + suite.assert_pending_rewards(ADDR2, 1, 2_500_000); // skip all and more, expiring suite.skip_blocks(1_100_000); @@ -2589,9 +2585,9 @@ fn test_pause_expired() { suite.assert_undistributed_rewards(1, 0); // check pending rewards - suite.assert_pending_rewards(MEMBER1, 1, 50_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 25_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 25_000_000); + suite.assert_pending_rewards(ADDR0, 1, 50_000_000); + suite.assert_pending_rewards(ADDR1, 1, 25_000_000); + suite.assert_pending_rewards(ADDR2, 1, 25_000_000); // pause suite.pause_emission(1); @@ -2600,9 +2596,9 @@ fn test_pause_expired() { suite.assert_undistributed_rewards(1, 0); // pending rewards should still exist - suite.assert_pending_rewards(MEMBER1, 1, 50_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 25_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 25_000_000); + suite.assert_pending_rewards(ADDR0, 1, 50_000_000); + suite.assert_pending_rewards(ADDR1, 1, 25_000_000); + suite.assert_pending_rewards(ADDR2, 1, 25_000_000); // fund suite.fund_native(1, coin(100_000_000, GOV_DENOM)); @@ -2620,9 +2616,9 @@ fn test_pause_expired() { suite.assert_undistributed_rewards(1, 0); // check pending rewards - suite.assert_pending_rewards(MEMBER1, 1, 100_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 50_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 50_000_000); + suite.assert_pending_rewards(ADDR0, 1, 100_000_000); + suite.assert_pending_rewards(ADDR1, 1, 50_000_000); + suite.assert_pending_rewards(ADDR2, 1, 50_000_000); } #[test] @@ -2641,22 +2637,22 @@ fn test_large_stake_before_claim() { suite.assert_ends_at(Expiration::AtHeight(33_333)); suite.assert_duration(1); - // MEMBER1 stake big amount of tokens + // ADDR0 stake big amount of tokens suite.skip_blocks(33_000); - suite.mint_native(coin(10_000, &suite.reward_denom), MEMBER1); - suite.stake_native_tokens(MEMBER1, 10_000); + suite.mint_native(coin(10_000, &suite.reward_denom), ADDR0); + suite.stake_native_tokens(ADDR0, 10_000); // ADD1 claims rewards in the next block suite.skip_blocks(1); - suite.claim_rewards(MEMBER1, 1); + suite.claim_rewards(ADDR0, 1); // skip to end suite.skip_blocks(100_000_000); // all users should be able to claim rewards - suite.claim_rewards(MEMBER1, 1); - suite.claim_rewards(MEMBER2, 1); - suite.claim_rewards(MEMBER3, 1); + suite.claim_rewards(ADDR0, 1); + suite.claim_rewards(ADDR1, 1); + suite.claim_rewards(ADDR2, 1); } #[test] @@ -2753,9 +2749,9 @@ fn test_fund_latest_native() { // skip all of the time suite.skip_blocks(2_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 100_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 50_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 50_000_000); + suite.assert_pending_rewards(ADDR0, 1, 100_000_000); + suite.assert_pending_rewards(ADDR1, 1, 50_000_000); + suite.assert_pending_rewards(ADDR2, 1, 50_000_000); } #[test] @@ -2783,9 +2779,9 @@ fn test_fund_latest_cw20() { // skip all of the time suite.skip_blocks(2_000_000); - suite.assert_pending_rewards(MEMBER1, 1, 100_000_000); - suite.assert_pending_rewards(MEMBER2, 1, 50_000_000); - suite.assert_pending_rewards(MEMBER3, 1, 50_000_000); + suite.assert_pending_rewards(ADDR0, 1, 100_000_000); + suite.assert_pending_rewards(ADDR1, 1, 50_000_000); + suite.assert_pending_rewards(ADDR2, 1, 50_000_000); } #[test] @@ -2865,12 +2861,12 @@ fn test_closed_funding() { ); // test fund from non-owner - suite.mint_native(coin(100, ALT_DENOM), MEMBER1); + suite.mint_native(coin(100, ALT_DENOM), ADDR0); let err: ContractError = suite .base .app .execute_contract( - Addr::unchecked(MEMBER1), + Addr::unchecked(ADDR0), suite.distribution_contract.clone(), &ExecuteMsg::Fund(FundMsg { id: 2 }), &[coin(100, ALT_DENOM)], @@ -2888,7 +2884,7 @@ fn test_closed_funding() { .base .app .execute_contract( - Addr::unchecked(MEMBER1), + Addr::unchecked(ADDR0), suite.distribution_contract.clone(), &ExecuteMsg::Fund(FundMsg { id: 2 }), &[coin(100, ALT_DENOM)], @@ -2936,9 +2932,9 @@ fn test_queries_before_funded() { .unwrap(); // users have no rewards - suite.assert_pending_rewards(MEMBER1, 2, 0); - suite.assert_pending_rewards(MEMBER2, 2, 0); - suite.assert_pending_rewards(MEMBER3, 2, 0); + suite.assert_pending_rewards(ADDR0, 2, 0); + suite.assert_pending_rewards(ADDR1, 2, 0); + suite.assert_pending_rewards(ADDR2, 2, 0); // ensure undistributed rewards are immediately 0 suite.assert_undistributed_rewards(2, 0); diff --git a/packages/dao-testing/src/suite/cw20_suite.rs b/packages/dao-testing/src/suite/cw20_suite.rs index a26b0b34e..278a9e0ec 100644 --- a/packages/dao-testing/src/suite/cw20_suite.rs +++ b/packages/dao-testing/src/suite/cw20_suite.rs @@ -28,23 +28,23 @@ impl<'a> DaoTestingSuiteCw20<'a> { initial_balances: vec![ Cw20Coin { - address: MEMBER1.to_string(), + address: ADDR0.to_string(), amount: Uint128::new(100), }, Cw20Coin { - address: MEMBER2.to_string(), + address: ADDR1.to_string(), amount: Uint128::new(200), }, Cw20Coin { - address: MEMBER3.to_string(), + address: ADDR2.to_string(), amount: Uint128::new(300), }, Cw20Coin { - address: MEMBER4.to_string(), + address: ADDR3.to_string(), amount: Uint128::new(300), }, Cw20Coin { - address: MEMBER5.to_string(), + address: ADDR4.to_string(), amount: Uint128::new(100), }, ], diff --git a/packages/dao-testing/src/suite/cw4_suite.rs b/packages/dao-testing/src/suite/cw4_suite.rs index d3a37c1a7..e2e233620 100644 --- a/packages/dao-testing/src/suite/cw4_suite.rs +++ b/packages/dao-testing/src/suite/cw4_suite.rs @@ -21,23 +21,23 @@ impl<'a> DaoTestingSuiteCw4<'a> { base, members: vec![ cw4::Member { - addr: MEMBER1.to_string(), + addr: ADDR0.to_string(), weight: 1, }, cw4::Member { - addr: MEMBER2.to_string(), + addr: ADDR1.to_string(), weight: 2, }, cw4::Member { - addr: MEMBER3.to_string(), + addr: ADDR2.to_string(), weight: 3, }, cw4::Member { - addr: MEMBER4.to_string(), + addr: ADDR3.to_string(), weight: 3, }, cw4::Member { - addr: MEMBER5.to_string(), + addr: ADDR4.to_string(), weight: 1, }, ], diff --git a/packages/dao-testing/src/suite/cw721_suite.rs b/packages/dao-testing/src/suite/cw721_suite.rs index 001f20c06..e3bf97190 100644 --- a/packages/dao-testing/src/suite/cw721_suite.rs +++ b/packages/dao-testing/src/suite/cw721_suite.rs @@ -31,25 +31,25 @@ impl<'a> DaoTestingSuiteCw721<'a> { base, initial_nfts: vec![ + InitialNft { + token_id: "0".to_string(), + owner: ADDR0.to_string(), + }, InitialNft { token_id: "1".to_string(), - owner: MEMBER1.to_string(), + owner: ADDR1.to_string(), }, InitialNft { token_id: "2".to_string(), - owner: MEMBER2.to_string(), + owner: ADDR2.to_string(), }, InitialNft { token_id: "3".to_string(), - owner: MEMBER3.to_string(), + owner: ADDR3.to_string(), }, InitialNft { token_id: "4".to_string(), - owner: MEMBER4.to_string(), - }, - InitialNft { - token_id: "5".to_string(), - owner: MEMBER5.to_string(), + owner: ADDR4.to_string(), }, ], unstaking_duration: None, diff --git a/packages/dao-testing/src/suite/mod.rs b/packages/dao-testing/src/suite/mod.rs index 312380ede..c42cf3e87 100644 --- a/packages/dao-testing/src/suite/mod.rs +++ b/packages/dao-testing/src/suite/mod.rs @@ -6,11 +6,11 @@ mod token_suite; pub const OWNER: &str = "owner"; -pub const MEMBER1: &str = "member1"; -pub const MEMBER2: &str = "member2"; -pub const MEMBER3: &str = "member3"; -pub const MEMBER4: &str = "member4"; -pub const MEMBER5: &str = "member5"; +pub const ADDR0: &str = "addr0"; +pub const ADDR1: &str = "addr1"; +pub const ADDR2: &str = "addr2"; +pub const ADDR3: &str = "addr3"; +pub const ADDR4: &str = "addr4"; pub const GOV_DENOM: &str = "ugovtoken"; diff --git a/packages/dao-testing/src/suite/token_suite.rs b/packages/dao-testing/src/suite/token_suite.rs index 35e686f70..d1bde79ad 100644 --- a/packages/dao-testing/src/suite/token_suite.rs +++ b/packages/dao-testing/src/suite/token_suite.rs @@ -27,23 +27,23 @@ impl<'a> DaoTestingSuiteToken<'a> { initial_balances: vec![ InitialBalance { - address: MEMBER1.to_string(), + address: ADDR0.to_string(), amount: Uint128::new(100), }, InitialBalance { - address: MEMBER2.to_string(), + address: ADDR1.to_string(), amount: Uint128::new(200), }, InitialBalance { - address: MEMBER3.to_string(), + address: ADDR2.to_string(), amount: Uint128::new(300), }, InitialBalance { - address: MEMBER4.to_string(), + address: ADDR3.to_string(), amount: Uint128::new(300), }, InitialBalance { - address: MEMBER5.to_string(), + address: ADDR4.to_string(), amount: Uint128::new(100), }, ], From 7b7af88c2d9c0671b638f14f991feb981d7ed63f Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 17 Oct 2024 01:32:02 -0400 Subject: [PATCH 13/24] added basic suite tests for delegations --- .../dao-vote-delegation/src/contract.rs | 7 + .../delegation/dao-vote-delegation/src/lib.rs | 4 +- .../dao-vote-delegation/src/testing/mod.rs | 1 + .../dao-vote-delegation/src/testing/tests.rs | 174 ++++++++++++++++++ packages/dao-testing/src/suite/base.rs | 86 ++++++++- 5 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 contracts/delegation/dao-vote-delegation/src/testing/mod.rs create mode 100644 contracts/delegation/dao-vote-delegation/src/testing/tests.rs diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 846384b94..037ebea14 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -75,6 +75,13 @@ pub fn instantiate( )?; VP_CAP_PERCENT.save(deps.storage, &msg.vp_cap_percent, env.block.height)?; + // initialize voting power changed hook callers + if let Some(vp_hook_callers) = msg.vp_hook_callers { + for caller in vp_hook_callers { + VOTING_POWER_HOOK_CALLERS.save(deps.storage, deps.api.addr_validate(&caller)?, &())?; + } + } + // sync proposal modules with no limit if not disabled. this should succeed // for most DAOs as the query will not run out of gas with only a few // proposal modules. diff --git a/contracts/delegation/dao-vote-delegation/src/lib.rs b/contracts/delegation/dao-vote-delegation/src/lib.rs index 5e7eaa5cc..167195f08 100644 --- a/contracts/delegation/dao-vote-delegation/src/lib.rs +++ b/contracts/delegation/dao-vote-delegation/src/lib.rs @@ -7,7 +7,7 @@ mod hooks; pub mod msg; pub mod state; -// #[cfg(test)] -// mod testing; +#[cfg(test)] +mod testing; pub use crate::error::ContractError; diff --git a/contracts/delegation/dao-vote-delegation/src/testing/mod.rs b/contracts/delegation/dao-vote-delegation/src/testing/mod.rs new file mode 100644 index 000000000..15ab56057 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/testing/mod.rs @@ -0,0 +1 @@ +pub mod tests; diff --git a/contracts/delegation/dao-vote-delegation/src/testing/tests.rs b/contracts/delegation/dao-vote-delegation/src/testing/tests.rs new file mode 100644 index 000000000..55f2c875e --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/testing/tests.rs @@ -0,0 +1,174 @@ +use cosmwasm_std::{Addr, Decimal, Empty, Uint128}; +use cw_multi_test::{Contract, ContractWrapper}; +use dao_testing::{DaoTestingSuite, DaoTestingSuiteBase, Executor, ADDR0, ADDR1, ADDR2}; +use dao_voting::delegation::{DelegationResponse, DelegationsResponse}; + +pub fn dao_vote_delegation_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +#[test] +fn test_setup() { + let mut base = DaoTestingSuiteBase::base(); + let mut suite = base.cw4(); + let dao = suite.dao(); + + let code_id = suite.base.app.store_code(dao_vote_delegation_contract()); + let delegation_addr = suite + .base + .app + .instantiate_contract( + code_id, + dao.core_addr.clone(), + &crate::msg::InstantiateMsg { + dao: None, + vp_hook_callers: Some(vec![dao.x.group_addr.to_string()]), + no_sync_proposal_modules: None, + vp_cap_percent: Some(Decimal::percent(50)), + delegation_validity_blocks: Some(100), + }, + &[], + "delegation", + None, + ) + .unwrap(); + + // register addr0 as a delegate + suite + .base + .app + .execute_contract( + Addr::unchecked(ADDR0), + delegation_addr.clone(), + &crate::msg::ExecuteMsg::Register {}, + &[], + ) + .unwrap(); + + // delegate 100% of addr1's voting power to addr0 + suite + .base + .app + .execute_contract( + Addr::unchecked(ADDR1), + delegation_addr.clone(), + &crate::msg::ExecuteMsg::Delegate { + delegate: ADDR0.to_string(), + percent: Decimal::percent(100), + }, + &[], + ) + .unwrap(); + + // delegations take effect on the next block + suite.base.advance_block(); + + let delegations: DelegationsResponse = suite + .querier() + .query_wasm_smart( + &delegation_addr, + &crate::msg::QueryMsg::Delegations { + delegator: ADDR1.to_string(), + height: None, + offset: None, + limit: None, + }, + ) + .unwrap(); + + assert_eq!(delegations.delegations.len(), 1); + assert_eq!( + delegations.delegations[0], + DelegationResponse { + delegate: Addr::unchecked(ADDR0), + percent: Decimal::percent(100), + active: true, + } + ); + + // propose a proposal + let (proposal_module, id1, p1) = + dao.propose_single_choice(&mut suite.base.app, ADDR0, "test proposal 1", vec![]); + + // ensure delegation is correctly applied to proposal + let udvp: dao_voting::delegation::UnvotedDelegatedVotingPowerResponse = suite + .querier() + .query_wasm_smart( + &delegation_addr, + &crate::msg::QueryMsg::UnvotedDelegatedVotingPower { + delegate: ADDR0.to_string(), + proposal_module: proposal_module.to_string(), + proposal_id: id1, + height: p1.start_height, + }, + ) + .unwrap(); + assert_eq!( + udvp.effective, + Uint128::from(suite.members[1].weight as u128) + ); + + // set delegation to 50% + suite + .base + .app + .execute_contract( + Addr::unchecked(ADDR1), + delegation_addr.clone(), + &crate::msg::ExecuteMsg::Delegate { + delegate: ADDR0.to_string(), + percent: Decimal::percent(50), + }, + &[], + ) + .unwrap(); + + // delegations take effect on the next block + suite.base.advance_block(); + + // propose a proposal + let (_, id2, p2) = + dao.propose_single_choice(&mut suite.base.app, ADDR2, "test proposal 2", vec![]); + + // ensure delegation is correctly applied to new proposal + let udvp: dao_voting::delegation::UnvotedDelegatedVotingPowerResponse = suite + .querier() + .query_wasm_smart( + &delegation_addr, + &crate::msg::QueryMsg::UnvotedDelegatedVotingPower { + delegate: ADDR0.to_string(), + proposal_module: proposal_module.to_string(), + proposal_id: id2, + height: p2.start_height, + }, + ) + .unwrap(); + assert_eq!( + udvp.effective, + Uint128::from((suite.members[1].weight / 2) as u128) + ); + + // ensure old delegation is still applied to old proposal + let udvp: dao_voting::delegation::UnvotedDelegatedVotingPowerResponse = suite + .querier() + .query_wasm_smart( + &delegation_addr, + &crate::msg::QueryMsg::UnvotedDelegatedVotingPower { + delegate: ADDR0.to_string(), + proposal_module: proposal_module.to_string(), + proposal_id: id1, + height: p1.start_height, + }, + ) + .unwrap(); + assert_eq!( + udvp.effective, + Uint128::from(suite.members[1].weight as u128) + ); +} diff --git a/packages/dao-testing/src/suite/base.rs b/packages/dao-testing/src/suite/base.rs index 85ec6d559..cadac5517 100644 --- a/packages/dao-testing/src/suite/base.rs +++ b/packages/dao-testing/src/suite/base.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::{to_json_binary, Addr, Empty, QuerierWrapper, Timestamp}; +use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, Empty, QuerierWrapper, Timestamp}; use cw20::Cw20Coin; use cw_multi_test::{App, Executor}; use cw_utils::Duration; @@ -10,10 +10,65 @@ use crate::contracts::*; pub struct TestDao { pub core_addr: Addr, pub voting_module_addr: Addr, - pub proposal_modules: Vec, + /// proposal modules in the form (pre-propose module, proposal module). if + /// the pre-propose module is None, then it does not exist. + pub proposal_modules: Vec<(Option, Addr)>, pub x: Extra, } +impl TestDao { + /// propose a single choice proposal and return the proposal module address, + /// proposal ID, and proposal + pub fn propose_single_choice( + &self, + app: &mut App, + proposer: impl Into, + title: impl Into, + msgs: Vec, + ) -> ( + Addr, + u64, + dao_proposal_single::proposal::SingleChoiceProposal, + ) { + let pre_propose_msg = dao_pre_propose_single::ExecuteMsg::Propose { + msg: dao_pre_propose_single::ProposeMessage::Propose { + title: title.into(), + description: "".to_string(), + msgs, + vote: None, + }, + }; + + let (pre_propose_module, proposal_module) = &self.proposal_modules[0]; + + app.execute_contract( + Addr::unchecked(proposer.into()), + pre_propose_module.as_ref().unwrap().clone(), + &pre_propose_msg, + &[], + ) + .unwrap(); + + let proposal_id: u64 = app + .wrap() + .query_wasm_smart( + proposal_module.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalCount {}, + ) + .unwrap(); + + let res: dao_proposal_single::query::ProposalResponse = app + .wrap() + .query_wasm_smart( + proposal_module.clone(), + &dao_proposal_single::msg::QueryMsg::Proposal { proposal_id }, + ) + .unwrap(); + + (proposal_module.clone(), proposal_id, res.proposal) + } +} + pub struct DaoTestingSuiteBase { pub app: App, @@ -97,6 +152,7 @@ pub trait DaoTestingSuite { }, close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(dao_interface::state::Admin::CoreModule {}), @@ -134,6 +190,7 @@ pub trait DaoTestingSuite { }, close_proposal_on_execution_failure: true, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(dao_interface::state::Admin::CoreModule {}), @@ -300,7 +357,7 @@ impl DaoTestingSuiteBase { let core = Addr::unchecked(instantiate_event.attributes[0].value.clone()); // get voting module address - let voting_module: Addr = self + let voting_module_addr: Addr = self .app .wrap() .query_wasm_smart(&core, &dao_interface::msg::QueryMsg::VotingModule {}) @@ -319,9 +376,30 @@ impl DaoTestingSuiteBase { ) .unwrap(); + let proposal_modules = proposal_modules + .into_iter() + .map(|p| -> (Option, Addr) { + let pre_propose_module: dao_voting::pre_propose::ProposalCreationPolicy = self + .app + .wrap() + .query_wasm_smart( + &p.address, + &dao_proposal_single::msg::QueryMsg::ProposalCreationPolicy {}, + ) + .unwrap(); + + match pre_propose_module { + dao_voting::pre_propose::ProposalCreationPolicy::Anyone {} => (None, p.address), + dao_voting::pre_propose::ProposalCreationPolicy::Module { addr } => { + (Some(addr), p.address) + } + } + }) + .collect::>(); + TestDao { core_addr: core, - voting_module_addr: voting_module, + voting_module_addr, proposal_modules, x: Empty::default(), } From cca212e4ec264d4af41a88f319dd8ff18e0a97bb Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 17 Oct 2024 09:41:32 -0400 Subject: [PATCH 14/24] fixed integration tests --- contracts/external/cw-admin-factory/src/integration_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/external/cw-admin-factory/src/integration_tests.rs b/contracts/external/cw-admin-factory/src/integration_tests.rs index f5533e0bd..71d1b0bb8 100644 --- a/contracts/external/cw-admin-factory/src/integration_tests.rs +++ b/contracts/external/cw-admin-factory/src/integration_tests.rs @@ -91,6 +91,7 @@ fn test_set_self_admin_instantiate2() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(Admin::CoreModule {}), From bd5d44d9721ee11e4be69fd7b5e14fea4bdfae5a Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 17 Oct 2024 10:19:57 -0400 Subject: [PATCH 15/24] fixed integration tests --- .../dao-voting-cw721-staked/src/testing/integration_tests.rs | 1 + .../dao-voting-cw721-staked/src/testing/test_tube_env.rs | 1 + .../src/tests/test_tube/integration_tests.rs | 4 ++++ .../dao-voting-token-staked/src/tests/test_tube/test_env.rs | 1 + 4 files changed, 7 insertions(+) diff --git a/contracts/voting/dao-voting-cw721-staked/src/testing/integration_tests.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/integration_tests.rs index aedf0ca32..4739124f3 100644 --- a/contracts/voting/dao-voting-cw721-staked/src/testing/integration_tests.rs +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/integration_tests.rs @@ -113,6 +113,7 @@ fn test_full_integration_with_factory() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(Admin::CoreModule {}), diff --git a/contracts/voting/dao-voting-cw721-staked/src/testing/test_tube_env.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/test_tube_env.rs index c9d462213..d8a67b05f 100644 --- a/contracts/voting/dao-voting-cw721-staked/src/testing/test_tube_env.rs +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/test_tube_env.rs @@ -137,6 +137,7 @@ impl TestEnvBuilder { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(Admin::CoreModule {}), diff --git a/contracts/voting/dao-voting-token-staked/src/tests/test_tube/integration_tests.rs b/contracts/voting/dao-voting-token-staked/src/tests/test_tube/integration_tests.rs index 77effcda6..07fe47e87 100644 --- a/contracts/voting/dao-voting-token-staked/src/tests/test_tube/integration_tests.rs +++ b/contracts/voting/dao-voting-token-staked/src/tests/test_tube/integration_tests.rs @@ -397,6 +397,7 @@ fn test_factory() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(Admin::CoreModule {}), @@ -507,6 +508,7 @@ fn test_factory_funds_pass_through() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(Admin::CoreModule {}), @@ -633,6 +635,7 @@ fn test_factory_no_callback() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(Admin::CoreModule {}), @@ -714,6 +717,7 @@ fn test_factory_wrong_callback() { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(Admin::CoreModule {}), diff --git a/contracts/voting/dao-voting-token-staked/src/tests/test_tube/test_env.rs b/contracts/voting/dao-voting-token-staked/src/tests/test_tube/test_env.rs index 3c93c9ee6..416c955ae 100644 --- a/contracts/voting/dao-voting-token-staked/src/tests/test_tube/test_env.rs +++ b/contracts/voting/dao-voting-token-staked/src/tests/test_tube/test_env.rs @@ -248,6 +248,7 @@ impl TestEnvBuilder { close_proposal_on_execution_failure: false, pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, veto: None, + delegation_module: None, }) .unwrap(), admin: Some(Admin::CoreModule {}), From f0b167ad7c26e8588e3173edce005b258e190663 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 17 Oct 2024 18:23:00 -0400 Subject: [PATCH 16/24] added deref and derefmut to improve DAO testing suite API --- .../dao-vote-delegation/src/testing/tests.rs | 105 +++++------ packages/dao-testing/src/suite/base.rs | 169 ++++++++++++------ packages/dao-testing/src/suite/cw20_suite.rs | 72 ++++---- packages/dao-testing/src/suite/cw4_suite.rs | 28 ++- packages/dao-testing/src/suite/cw721_suite.rs | 68 ++++--- packages/dao-testing/src/suite/token_suite.rs | 65 ++++--- 6 files changed, 302 insertions(+), 205 deletions(-) diff --git a/contracts/delegation/dao-vote-delegation/src/testing/tests.rs b/contracts/delegation/dao-vote-delegation/src/testing/tests.rs index 55f2c875e..9743cb017 100644 --- a/contracts/delegation/dao-vote-delegation/src/testing/tests.rs +++ b/contracts/delegation/dao-vote-delegation/src/testing/tests.rs @@ -1,6 +1,6 @@ use cosmwasm_std::{Addr, Decimal, Empty, Uint128}; use cw_multi_test::{Contract, ContractWrapper}; -use dao_testing::{DaoTestingSuite, DaoTestingSuiteBase, Executor, ADDR0, ADDR1, ADDR2}; +use dao_testing::{DaoTestingSuite, DaoTestingSuiteBase, ADDR0, ADDR1, ADDR2}; use dao_voting::delegation::{DelegationResponse, DelegationsResponse}; pub fn dao_vote_delegation_contract() -> Box> { @@ -19,55 +19,43 @@ fn test_setup() { let mut suite = base.cw4(); let dao = suite.dao(); - let code_id = suite.base.app.store_code(dao_vote_delegation_contract()); - let delegation_addr = suite - .base - .app - .instantiate_contract( - code_id, - dao.core_addr.clone(), - &crate::msg::InstantiateMsg { - dao: None, - vp_hook_callers: Some(vec![dao.x.group_addr.to_string()]), - no_sync_proposal_modules: None, - vp_cap_percent: Some(Decimal::percent(50)), - delegation_validity_blocks: Some(100), - }, - &[], - "delegation", - None, - ) - .unwrap(); + let code_id = suite.store(dao_vote_delegation_contract); + let delegation_addr = suite.instantiate( + code_id, + &dao.core_addr, + &crate::msg::InstantiateMsg { + dao: None, + vp_hook_callers: Some(vec![dao.x.group_addr.to_string()]), + no_sync_proposal_modules: None, + vp_cap_percent: Some(Decimal::percent(50)), + delegation_validity_blocks: Some(100), + }, + &[], + "delegation", + None, + ); // register addr0 as a delegate - suite - .base - .app - .execute_contract( - Addr::unchecked(ADDR0), - delegation_addr.clone(), - &crate::msg::ExecuteMsg::Register {}, - &[], - ) - .unwrap(); + suite.execute_smart( + ADDR0, + &delegation_addr, + &crate::msg::ExecuteMsg::Register {}, + &[], + ); // delegate 100% of addr1's voting power to addr0 - suite - .base - .app - .execute_contract( - Addr::unchecked(ADDR1), - delegation_addr.clone(), - &crate::msg::ExecuteMsg::Delegate { - delegate: ADDR0.to_string(), - percent: Decimal::percent(100), - }, - &[], - ) - .unwrap(); + suite.execute_smart( + ADDR1, + &delegation_addr, + &crate::msg::ExecuteMsg::Delegate { + delegate: ADDR0.to_string(), + percent: Decimal::percent(100), + }, + &[], + ); // delegations take effect on the next block - suite.base.advance_block(); + suite.advance_block(); let delegations: DelegationsResponse = suite .querier() @@ -94,7 +82,7 @@ fn test_setup() { // propose a proposal let (proposal_module, id1, p1) = - dao.propose_single_choice(&mut suite.base.app, ADDR0, "test proposal 1", vec![]); + dao.propose_single_choice(&mut suite, ADDR0, "test proposal 1", vec![]); // ensure delegation is correctly applied to proposal let udvp: dao_voting::delegation::UnvotedDelegatedVotingPowerResponse = suite @@ -115,26 +103,21 @@ fn test_setup() { ); // set delegation to 50% - suite - .base - .app - .execute_contract( - Addr::unchecked(ADDR1), - delegation_addr.clone(), - &crate::msg::ExecuteMsg::Delegate { - delegate: ADDR0.to_string(), - percent: Decimal::percent(50), - }, - &[], - ) - .unwrap(); + suite.execute_smart( + ADDR1, + &delegation_addr, + &crate::msg::ExecuteMsg::Delegate { + delegate: ADDR0.to_string(), + percent: Decimal::percent(50), + }, + &[], + ); // delegations take effect on the next block - suite.base.advance_block(); + suite.advance_block(); // propose a proposal - let (_, id2, p2) = - dao.propose_single_choice(&mut suite.base.app, ADDR2, "test proposal 2", vec![]); + let (_, id2, p2) = dao.propose_single_choice(&mut suite, ADDR2, "test proposal 2", vec![]); // ensure delegation is correctly applied to new proposal let udvp: dao_voting::delegation::UnvotedDelegatedVotingPowerResponse = suite diff --git a/packages/dao-testing/src/suite/base.rs b/packages/dao-testing/src/suite/base.rs index cadac5517..8a27e8e99 100644 --- a/packages/dao-testing/src/suite/base.rs +++ b/packages/dao-testing/src/suite/base.rs @@ -1,7 +1,13 @@ -use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, Empty, QuerierWrapper, Timestamp}; +use std::{ + fmt::{Debug, Display}, + ops::{Deref, DerefMut}, +}; + +use cosmwasm_std::{to_json_binary, Addr, Coin, CosmosMsg, Empty, QuerierWrapper, Timestamp}; use cw20::Cw20Coin; -use cw_multi_test::{App, Executor}; +use cw_multi_test::{App, AppResponse, Contract, Executor}; use cw_utils::Duration; +use serde::Serialize; use super::*; use crate::contracts::*; @@ -21,7 +27,7 @@ impl TestDao { /// proposal ID, and proposal pub fn propose_single_choice( &self, - app: &mut App, + base: &mut DaoTestingSuiteBase, proposer: impl Into, title: impl Into, msgs: Vec, @@ -41,24 +47,23 @@ impl TestDao { let (pre_propose_module, proposal_module) = &self.proposal_modules[0]; - app.execute_contract( - Addr::unchecked(proposer.into()), - pre_propose_module.as_ref().unwrap().clone(), + base.execute_smart( + proposer, + pre_propose_module.as_ref().unwrap(), &pre_propose_msg, &[], - ) - .unwrap(); + ); - let proposal_id: u64 = app - .wrap() + let proposal_id: u64 = base + .querier() .query_wasm_smart( proposal_module.clone(), &dao_proposal_single::msg::QueryMsg::ProposalCount {}, ) .unwrap(); - let res: dao_proposal_single::query::ProposalResponse = app - .wrap() + let res: dao_proposal_single::query::ProposalResponse = base + .querier() .query_wasm_smart( proposal_module.clone(), &dao_proposal_single::msg::QueryMsg::Proposal { proposal_id }, @@ -99,7 +104,7 @@ pub struct DaoTestingSuiteBase { pub admin_factory_addr: Addr, } -pub trait DaoTestingSuite { +pub trait DaoTestingSuite: Deref + DerefMut { /// get the testing suite base fn base(&self) -> &DaoTestingSuiteBase; @@ -218,11 +223,6 @@ pub trait DaoTestingSuite { dao } - - /// get the app querier - fn querier(&self) -> QuerierWrapper<'_> { - self.base().app.wrap() - } } // CONSTRUCTOR @@ -296,24 +296,20 @@ impl DaoTestingSuiteBase { } } - pub fn instantiate_cw20(&mut self, name: &str, initial_balances: Vec) -> Addr { - self.app - .instantiate_contract( - self.cw20_base_id, - Addr::unchecked(OWNER), - &cw20_base::msg::InstantiateMsg { - name: name.to_string(), - symbol: name.to_string(), - decimals: 6, - initial_balances, - mint: None, - marketing: None, - }, - &[], - "cw20", - None, - ) - .unwrap() + pub fn cw4(&mut self) -> DaoTestingSuiteCw4 { + DaoTestingSuiteCw4::new(self) + } + + pub fn cw20(&mut self) -> DaoTestingSuiteCw20 { + DaoTestingSuiteCw20::new(self) + } + + pub fn cw721(&mut self) -> DaoTestingSuiteCw721 { + DaoTestingSuiteCw721::new(self) + } + + pub fn token(&mut self) -> DaoTestingSuiteToken { + DaoTestingSuiteToken::new(self) } } @@ -404,28 +400,101 @@ impl DaoTestingSuiteBase { x: Empty::default(), } } +} - pub fn cw4(&mut self) -> DaoTestingSuiteCw4 { - DaoTestingSuiteCw4::new(self) +// UTILITIES +impl DaoTestingSuiteBase { + /// get the app querier + pub fn querier(&self) -> QuerierWrapper<'_> { + self.app.wrap() } - pub fn cw20(&mut self) -> DaoTestingSuiteCw20 { - DaoTestingSuiteCw20::new(self) + /// advance the block height by one + pub fn advance_block(&mut self) { + self.app.update_block(|b| b.height += 1); } - pub fn cw721(&mut self) -> DaoTestingSuiteCw721 { - DaoTestingSuiteCw721::new(self) + /// store a contract given its maker function and return its code ID + pub fn store(&mut self, contract_maker: impl FnOnce() -> Box>) -> u64 { + self.app.store_code(contract_maker()) } - pub fn token(&mut self) -> DaoTestingSuiteToken { - DaoTestingSuiteToken::new(self) + /// instantiate a smart contract and return its address + pub fn instantiate( + &mut self, + code_id: u64, + sender: impl Into, + init_msg: &T, + send_funds: &[Coin], + label: impl Into, + admin: Option, + ) -> Addr { + self.app + .instantiate_contract( + code_id, + Addr::unchecked(sender), + init_msg, + send_funds, + label.into(), + admin.map(|a| a.into()), + ) + .unwrap() } -} -// UTILITIES -impl DaoTestingSuiteBase { - /// advance the block height by one - pub fn advance_block(&mut self) { - self.app.update_block(|b| b.height += 1); + /// execute a smart contract and expect it to succeed + pub fn execute_smart( + &mut self, + sender: impl Into, + contract_addr: impl Into, + msg: &T, + send_funds: &[Coin], + ) -> AppResponse { + self.app + .execute_contract( + Addr::unchecked(sender.into()), + Addr::unchecked(contract_addr.into()), + msg, + send_funds, + ) + .unwrap() + } + + /// execute a smart contract and return the error + pub fn execute_smart_err( + &mut self, + sender: impl Into, + contract_addr: impl Into, + msg: &T, + send_funds: &[Coin], + ) -> E { + self.app + .execute_contract( + Addr::unchecked(sender.into()), + Addr::unchecked(contract_addr.into()), + msg, + send_funds, + ) + .unwrap_err() + .downcast() + .unwrap() + } + + /// instantiate a cw20 contract and return its address + pub fn instantiate_cw20(&mut self, name: &str, initial_balances: Vec) -> Addr { + self.instantiate( + self.cw20_base_id, + OWNER, + &cw20_base::msg::InstantiateMsg { + name: name.to_string(), + symbol: name.to_string(), + decimals: 6, + initial_balances, + mint: None, + marketing: None, + }, + &[], + "cw20", + None, + ) } } diff --git a/packages/dao-testing/src/suite/cw20_suite.rs b/packages/dao-testing/src/suite/cw20_suite.rs index 278a9e0ec..6e2931362 100644 --- a/packages/dao-testing/src/suite/cw20_suite.rs +++ b/packages/dao-testing/src/suite/cw20_suite.rs @@ -1,3 +1,5 @@ +use std::ops::{Deref, DerefMut}; + use cosmwasm_std::{to_json_binary, Addr, Uint128}; use cw20::Cw20Coin; use cw_utils::Duration; @@ -21,6 +23,20 @@ pub struct Cw20DaoExtra { pub type Cw20TestDao = TestDao; +impl<'a> Deref for DaoTestingSuiteCw20<'a> { + type Target = DaoTestingSuiteBase; + + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl<'a> DerefMut for DaoTestingSuiteCw20<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.base + } +} + impl<'a> DaoTestingSuiteCw20<'a> { pub fn new(base: &'a mut DaoTestingSuiteBase) -> Self { Self { @@ -87,19 +103,16 @@ impl<'a> DaoTestingSuiteCw20<'a> { staker: impl Into, amount: impl Into, ) { - self.base - .app - .execute_contract( - Addr::unchecked(staker), - dao.x.cw20_addr.clone(), - &cw20::Cw20ExecuteMsg::Send { - contract: dao.x.staking_addr.to_string(), - amount: amount.into(), - msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), - }, - &[], - ) - .unwrap(); + self.execute_smart( + staker, + &dao.x.cw20_addr, + &cw20::Cw20ExecuteMsg::Send { + contract: dao.x.staking_addr.to_string(), + amount: amount.into(), + msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ); } /// unstake tokens @@ -109,17 +122,14 @@ impl<'a> DaoTestingSuiteCw20<'a> { staker: impl Into, amount: impl Into, ) { - self.base - .app - .execute_contract( - Addr::unchecked(staker), - dao.x.staking_addr.clone(), - &cw20_stake::msg::ExecuteMsg::Unstake { - amount: amount.into(), - }, - &[], - ) - .unwrap(); + self.execute_smart( + staker, + &dao.x.staking_addr, + &cw20_stake::msg::ExecuteMsg::Unstake { + amount: amount.into(), + }, + &[], + ); } /// stake all initial balances and progress one block @@ -129,32 +139,32 @@ impl<'a> DaoTestingSuiteCw20<'a> { } // staking takes effect at the next block - self.base.advance_block(); + self.advance_block(); } } impl<'a> DaoTestingSuite for DaoTestingSuiteCw20<'a> { fn base(&self) -> &DaoTestingSuiteBase { - self.base + self } fn base_mut(&mut self) -> &mut DaoTestingSuiteBase { - self.base + self } fn get_voting_module_info(&self) -> dao_interface::state::ModuleInstantiateInfo { dao_interface::state::ModuleInstantiateInfo { - code_id: self.base.voting_cw20_staked_id, + code_id: self.voting_cw20_staked_id, msg: to_json_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { token_info: dao_voting_cw20_staked::msg::TokenInfo::New { - code_id: self.base.cw20_base_id, + code_id: self.cw20_base_id, label: "voting token".to_string(), name: "Voting Token".to_string(), symbol: "VOTE".to_string(), decimals: 6, initial_balances: self.initial_balances.clone(), marketing: None, - staking_code_id: self.base.cw20_stake_id, + staking_code_id: self.cw20_stake_id, unstaking_duration: self.unstaking_duration, initial_dao_balance: Some(self.initial_dao_balance), }, @@ -196,7 +206,7 @@ impl<'a> DaoTestingSuite for DaoTestingSuiteCw20<'a> { } // staking takes effect at the next block - self.base.advance_block(); + self.advance_block(); } } diff --git a/packages/dao-testing/src/suite/cw4_suite.rs b/packages/dao-testing/src/suite/cw4_suite.rs index e2e233620..2910de7a7 100644 --- a/packages/dao-testing/src/suite/cw4_suite.rs +++ b/packages/dao-testing/src/suite/cw4_suite.rs @@ -1,3 +1,5 @@ +use std::ops::{Deref, DerefMut}; + use cosmwasm_std::{to_json_binary, Addr}; use super::*; @@ -15,6 +17,20 @@ pub struct Cw4DaoExtra { pub type Cw4TestDao = TestDao; +impl<'a> Deref for DaoTestingSuiteCw4<'a> { + type Target = DaoTestingSuiteBase; + + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl<'a> DerefMut for DaoTestingSuiteCw4<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.base + } +} + impl<'a> DaoTestingSuiteCw4<'a> { pub fn new(base: &'a mut DaoTestingSuiteBase) -> Self { Self { @@ -52,19 +68,19 @@ impl<'a> DaoTestingSuiteCw4<'a> { impl<'a> DaoTestingSuite for DaoTestingSuiteCw4<'a> { fn base(&self) -> &DaoTestingSuiteBase { - self.base + self } fn base_mut(&mut self) -> &mut DaoTestingSuiteBase { - self.base + self } fn get_voting_module_info(&self) -> dao_interface::state::ModuleInstantiateInfo { dao_interface::state::ModuleInstantiateInfo { - code_id: self.base.voting_cw4_id, + code_id: self.voting_cw4_id, msg: to_json_binary(&dao_voting_cw4::msg::InstantiateMsg { group_contract: dao_voting_cw4::msg::GroupContract::New { - cw4_group_code_id: self.base.cw4_group_id, + cw4_group_code_id: self.cw4_group_id, initial_members: self.members.clone(), }, }) @@ -96,8 +112,8 @@ mod tests { #[test] fn dao_testing_suite_cw4() { - let mut suite = DaoTestingSuiteBase::base(); - let mut suite = suite.cw4(); + let mut base = DaoTestingSuiteBase::base(); + let mut suite = base.cw4(); let dao = suite.dao(); let voting_module: Addr = suite diff --git a/packages/dao-testing/src/suite/cw721_suite.rs b/packages/dao-testing/src/suite/cw721_suite.rs index e3bf97190..5ab18de7c 100644 --- a/packages/dao-testing/src/suite/cw721_suite.rs +++ b/packages/dao-testing/src/suite/cw721_suite.rs @@ -1,3 +1,5 @@ +use std::ops::{Deref, DerefMut}; + use cosmwasm_schema::cw_serde; use cosmwasm_std::{to_json_binary, Addr, Binary, Empty}; use cw_utils::Duration; @@ -25,6 +27,20 @@ pub struct Cw721DaoExtra { pub type Cw721TestDao = TestDao; +impl<'a> Deref for DaoTestingSuiteCw721<'a> { + type Target = DaoTestingSuiteBase; + + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl<'a> DerefMut for DaoTestingSuiteCw721<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.base + } +} + impl<'a> DaoTestingSuiteCw721<'a> { pub fn new(base: &'a mut DaoTestingSuiteBase) -> Self { Self { @@ -82,19 +98,16 @@ impl<'a> DaoTestingSuiteCw721<'a> { staker: impl Into, token_id: impl Into, ) { - self.base - .app - .execute_contract( - Addr::unchecked(staker), - dao.x.cw721_addr.clone(), - &cw721_base::msg::ExecuteMsg::::SendNft { - contract: dao.voting_module_addr.to_string(), - token_id: token_id.into(), - msg: Binary::default(), - }, - &[], - ) - .unwrap(); + self.execute_smart( + staker, + &dao.x.cw721_addr, + &cw721_base::msg::ExecuteMsg::::SendNft { + contract: dao.voting_module_addr.to_string(), + token_id: token_id.into(), + msg: Binary::default(), + }, + &[], + ); } /// unstake NFT @@ -104,35 +117,32 @@ impl<'a> DaoTestingSuiteCw721<'a> { staker: impl Into, token_id: impl Into, ) { - self.base - .app - .execute_contract( - Addr::unchecked(staker), - dao.voting_module_addr.clone(), - &dao_voting_cw721_staked::msg::ExecuteMsg::Unstake { - token_ids: vec![token_id.into()], - }, - &[], - ) - .unwrap(); + self.execute_smart( + staker, + &dao.voting_module_addr, + &dao_voting_cw721_staked::msg::ExecuteMsg::Unstake { + token_ids: vec![token_id.into()], + }, + &[], + ); } } impl<'a> DaoTestingSuite for DaoTestingSuiteCw721<'a> { fn base(&self) -> &DaoTestingSuiteBase { - self.base + self } fn base_mut(&mut self) -> &mut DaoTestingSuiteBase { - self.base + self } fn get_voting_module_info(&self) -> dao_interface::state::ModuleInstantiateInfo { dao_interface::state::ModuleInstantiateInfo { - code_id: self.base.voting_cw721_staked_id, + code_id: self.voting_cw721_staked_id, msg: to_json_binary(&dao_voting_cw721_staked::msg::InstantiateMsg { nft_contract: dao_voting_cw721_staked::msg::NftContract::New { - code_id: self.base.cw721_base_id, + code_id: self.cw721_base_id, label: "voting NFT".to_string(), msg: to_json_binary(&cw721_base::msg::InstantiateMsg { name: "Voting NFT".to_string(), @@ -185,7 +195,7 @@ impl<'a> DaoTestingSuite for DaoTestingSuiteCw721<'a> { } // staking takes effect at the next block - self.base.advance_block(); + self.advance_block(); } } diff --git a/packages/dao-testing/src/suite/token_suite.rs b/packages/dao-testing/src/suite/token_suite.rs index d1bde79ad..7dd0733d8 100644 --- a/packages/dao-testing/src/suite/token_suite.rs +++ b/packages/dao-testing/src/suite/token_suite.rs @@ -1,4 +1,6 @@ -use cosmwasm_std::{coins, to_json_binary, Addr, Uint128}; +use std::ops::{Deref, DerefMut}; + +use cosmwasm_std::{coins, to_json_binary, Uint128}; use cw_multi_test::{BankSudo, SudoMsg}; use cw_utils::Duration; use dao_interface::token::InitialBalance; @@ -20,6 +22,20 @@ pub struct TokenDaoExtra { pub type TokenTestDao = TestDao; +impl<'a> Deref for DaoTestingSuiteToken<'a> { + type Target = DaoTestingSuiteBase; + + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl<'a> DerefMut for DaoTestingSuiteToken<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.base + } +} + impl<'a> DaoTestingSuiteToken<'a> { pub fn new(base: &'a mut DaoTestingSuiteBase) -> Self { Self { @@ -77,8 +93,7 @@ impl<'a> DaoTestingSuiteToken<'a> { recipient: impl Into, amount: impl Into, ) { - self.base - .app + self.app .sudo(SudoMsg::Bank({ BankSudo::Mint { to_address: recipient.into(), @@ -95,15 +110,12 @@ impl<'a> DaoTestingSuiteToken<'a> { staker: impl Into, amount: impl Into, ) { - self.base - .app - .execute_contract( - Addr::unchecked(staker), - dao.voting_module_addr.clone(), - &dao_voting_token_staked::msg::ExecuteMsg::Stake {}, - &coins(amount.into(), &dao.x.denom), - ) - .unwrap(); + self.execute_smart( + staker, + &dao.voting_module_addr, + &dao_voting_token_staked::msg::ExecuteMsg::Stake {}, + &coins(amount.into(), &dao.x.denom), + ); } /// unstake tokens @@ -113,32 +125,29 @@ impl<'a> DaoTestingSuiteToken<'a> { staker: impl Into, amount: impl Into, ) { - self.base - .app - .execute_contract( - Addr::unchecked(staker), - dao.voting_module_addr.clone(), - &dao_voting_token_staked::msg::ExecuteMsg::Unstake { - amount: amount.into(), - }, - &[], - ) - .unwrap(); + self.execute_smart( + staker, + &dao.voting_module_addr, + &dao_voting_token_staked::msg::ExecuteMsg::Unstake { + amount: amount.into(), + }, + &[], + ); } } impl<'a> DaoTestingSuite for DaoTestingSuiteToken<'a> { fn base(&self) -> &DaoTestingSuiteBase { - self.base + self } fn base_mut(&mut self) -> &mut DaoTestingSuiteBase { - self.base + self } fn get_voting_module_info(&self) -> dao_interface::state::ModuleInstantiateInfo { dao_interface::state::ModuleInstantiateInfo { - code_id: self.base.voting_token_staked_id, + code_id: self.voting_token_staked_id, msg: to_json_binary(&dao_voting_token_staked::msg::InstantiateMsg { token_info: dao_voting_token_staked::msg::TokenInfo::Existing { denom: GOV_DENOM.to_string(), @@ -173,13 +182,13 @@ impl<'a> DaoTestingSuite for DaoTestingSuiteToken<'a> { } // staking takes effect at the next block - self.base.advance_block(); + self.advance_block(); } } #[cfg(test)] mod tests { - use cosmwasm_std::Uint128; + use cosmwasm_std::{Addr, Uint128}; use super::*; From e9ddb78e933c792d623c1cf0d11d68aae4a48de1 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 17 Oct 2024 19:53:46 -0400 Subject: [PATCH 17/24] added more delegation tests --- Cargo.lock | 1 + .../delegation/dao-vote-delegation/Cargo.toml | 1 + .../dao-vote-delegation/src/testing/mod.rs | 3 + .../dao-vote-delegation/src/testing/suite.rs | 438 ++++++++++++ .../dao-vote-delegation/src/testing/tests.rs | 676 +++++++++++++++--- packages/dao-interface/src/helpers.rs | 2 +- packages/dao-testing/src/suite/base.rs | 219 ++++-- packages/dao-testing/src/suite/cw20_suite.rs | 8 +- packages/dao-testing/src/suite/cw4_suite.rs | 4 +- packages/dao-testing/src/suite/cw721_suite.rs | 8 +- packages/dao-testing/src/suite/token_suite.rs | 8 +- packages/dao-voting/src/voting.rs | 9 + 12 files changed, 1177 insertions(+), 200 deletions(-) create mode 100644 contracts/delegation/dao-vote-delegation/src/testing/suite.rs diff --git a/Cargo.lock b/Cargo.lock index 80a895016..7d0401496 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2397,6 +2397,7 @@ dependencies = [ "cw721-base 0.18.0", "dao-hooks 2.6.0", "dao-interface 2.6.0", + "dao-proposal-sudo", "dao-testing", "dao-voting 2.6.0", "dao-voting-cw20-staked", diff --git a/contracts/delegation/dao-vote-delegation/Cargo.toml b/contracts/delegation/dao-vote-delegation/Cargo.toml index 0fb0d7957..086f9561d 100644 --- a/contracts/delegation/dao-vote-delegation/Cargo.toml +++ b/contracts/delegation/dao-vote-delegation/Cargo.toml @@ -45,3 +45,4 @@ dao-voting-cw4 = { workspace = true, features = ["library"] } dao-voting-token-staked = { workspace = true, features = ["library"] } dao-voting-cw721-staked = { workspace = true, features = ["library"] } dao-testing = { workspace = true } +dao-proposal-sudo = { workspace = true } diff --git a/contracts/delegation/dao-vote-delegation/src/testing/mod.rs b/contracts/delegation/dao-vote-delegation/src/testing/mod.rs index 15ab56057..591459a01 100644 --- a/contracts/delegation/dao-vote-delegation/src/testing/mod.rs +++ b/contracts/delegation/dao-vote-delegation/src/testing/mod.rs @@ -1 +1,4 @@ +pub mod suite; pub mod tests; + +pub use suite::*; diff --git a/contracts/delegation/dao-vote-delegation/src/testing/suite.rs b/contracts/delegation/dao-vote-delegation/src/testing/suite.rs new file mode 100644 index 000000000..72e91b953 --- /dev/null +++ b/contracts/delegation/dao-vote-delegation/src/testing/suite.rs @@ -0,0 +1,438 @@ +use std::ops::{Deref, DerefMut}; + +use cosmwasm_std::{Addr, Decimal, Uint128}; +use dao_interface::helpers::{OptionalUpdate, Update}; +use dao_testing::{Cw4TestDao, DaoTestingSuite, DaoTestingSuiteBase}; + +use super::tests::dao_vote_delegation_contract; + +pub struct DaoVoteDelegationTestingSuite { + /// base testing suite that we're extending + pub base: DaoTestingSuiteBase, + + // initial config + vp_cap_percent: Option, + delegation_validity_blocks: Option, + + /// cw4-group voting DAO + pub dao: Cw4TestDao, + /// members of the DAO + pub members: Vec, + + /// delegation code ID + pub delegation_code_id: u64, + /// delegation contract address + pub delegation_addr: Addr, +} + +// allow direct access to base testing suite methods +impl Deref for DaoVoteDelegationTestingSuite { + type Target = DaoTestingSuiteBase; + + fn deref(&self) -> &Self::Target { + &self.base + } +} + +// allow direct access to base testing suite methods +impl DerefMut for DaoVoteDelegationTestingSuite { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.base + } +} + +// CONSTRUCTOR +impl DaoVoteDelegationTestingSuite { + pub fn new() -> Self { + let mut base = DaoTestingSuiteBase::base(); + let mut suite = base.cw4(); + + let members = suite.members.clone(); + let dao = suite.dao(); + + let delegation_code_id = suite.store(dao_vote_delegation_contract); + + Self { + base, + + vp_cap_percent: None, + delegation_validity_blocks: None, + + dao, + members, + + delegation_code_id, + delegation_addr: Addr::unchecked(""), + } + } + + pub fn with_vp_cap_percent(mut self, vp_cap_percent: Decimal) -> Self { + self.vp_cap_percent = Some(vp_cap_percent); + self + } + + pub fn with_delegation_validity_blocks(mut self, delegation_validity_blocks: u64) -> Self { + self.delegation_validity_blocks = Some(delegation_validity_blocks); + self + } + + pub fn build(mut self) -> Self { + let code_id = self.delegation_code_id; + let core_addr = self.dao.core_addr.clone(); + let group_addr = self.dao.x.group_addr.to_string(); + let vp_cap_percent = self.vp_cap_percent; + let delegation_validity_blocks = self.delegation_validity_blocks; + + self.delegation_addr = self.instantiate( + code_id, + &core_addr, + &crate::msg::InstantiateMsg { + dao: None, + vp_hook_callers: Some(vec![group_addr]), + no_sync_proposal_modules: None, + vp_cap_percent, + delegation_validity_blocks, + }, + &[], + "delegation", + None, + ); + + self.setup_delegation_module(); + + self + } +} + +// EXECUTIONS +impl DaoVoteDelegationTestingSuite { + /// set up delegation module by adding necessary hooks and adding it to the + /// proposal modules + pub fn setup_delegation_module(&mut self) { + let dao = self.dao.clone(); + let delegation_addr = self.delegation_addr.to_string(); + + // add voting power changed hook to cw4-group + self.execute_smart_ok( + &dao.core_addr, + &dao.x.group_addr, + &cw4::Cw4ExecuteMsg::AddHook { + addr: delegation_addr.clone(), + }, + &[], + ); + + // add vote hook to all proposal modules + self.add_vote_hook(&dao, &delegation_addr); + + // set the delegation module for all proposal modules + self.set_delegation_module(&dao, &delegation_addr); + } + + /// register a user as a delegate + pub fn register(&mut self, delegate: impl Into) { + let delegation_addr = self.delegation_addr.clone(); + self.execute_smart_ok( + delegate, + delegation_addr, + &crate::msg::ExecuteMsg::Register {}, + &[], + ); + } + + /// unregister a delegate + pub fn unregister(&mut self, delegate: impl Into) { + let delegation_addr = self.delegation_addr.clone(); + self.execute_smart_ok( + delegate, + delegation_addr, + &crate::msg::ExecuteMsg::Unregister {}, + &[], + ); + } + + /// create or update a delegation + pub fn delegate( + &mut self, + delegator: impl Into, + delegate: impl Into, + percent: Decimal, + ) { + let delegation_addr = self.delegation_addr.clone(); + self.execute_smart_ok( + delegator, + delegation_addr, + &crate::msg::ExecuteMsg::Delegate { + delegate: delegate.into(), + percent, + }, + &[], + ); + } + + /// revoke a delegation + pub fn undelegate(&mut self, delegator: impl Into, delegate: impl Into) { + let delegation_addr = self.delegation_addr.clone(); + self.execute_smart_ok( + delegator, + delegation_addr, + &crate::msg::ExecuteMsg::Undelegate { + delegate: delegate.into(), + }, + &[], + ); + } + + /// update voting power hook callers + pub fn update_voting_power_hook_callers( + &mut self, + add: Option>, + remove: Option>, + ) { + let core_addr = self.dao.core_addr.clone(); + let delegation_addr = self.delegation_addr.clone(); + self.execute_smart_ok( + core_addr, + delegation_addr, + &crate::msg::ExecuteMsg::UpdateVotingPowerHookCallers { add, remove }, + &[], + ); + } + + /// sync proposal modules + pub fn sync_proposal_modules(&mut self, start_after: Option, limit: Option) { + let core_addr = self.dao.core_addr.clone(); + let delegation_addr = self.delegation_addr.clone(); + self.execute_smart_ok( + core_addr, + delegation_addr, + &crate::msg::ExecuteMsg::SyncProposalModules { start_after, limit }, + &[], + ); + } + + /// update VP cap percent + pub fn update_vp_cap_percent(&mut self, vp_cap_percent: Option) { + let core_addr = self.dao.core_addr.clone(); + let delegation_addr = self.delegation_addr.clone(); + self.execute_smart_ok( + core_addr, + delegation_addr, + &crate::msg::ExecuteMsg::UpdateConfig { + vp_cap_percent: OptionalUpdate(Some( + vp_cap_percent.map_or(Update::Clear, Update::Set), + )), + delegation_validity_blocks: OptionalUpdate(None), + }, + &[], + ); + } + + /// update delegation validity blocks + pub fn update_delegation_validity_blocks(&mut self, delegation_validity_blocks: Option) { + let core_addr = self.dao.core_addr.clone(); + let delegation_addr = self.delegation_addr.clone(); + self.execute_smart_ok( + core_addr, + delegation_addr, + &crate::msg::ExecuteMsg::UpdateConfig { + vp_cap_percent: OptionalUpdate(None), + delegation_validity_blocks: OptionalUpdate(Some( + delegation_validity_blocks.map_or(Update::Clear, Update::Set), + )), + }, + &[], + ); + } +} + +/// QUERIES +impl DaoVoteDelegationTestingSuite { + /// get the delegates + pub fn delegates( + &self, + start_after: Option, + limit: Option, + ) -> Vec { + self.querier() + .query_wasm_smart::( + &self.delegation_addr, + &crate::msg::QueryMsg::Delegates { start_after, limit }, + ) + .unwrap() + .delegates + } + + /// get the delegations + pub fn delegations( + &self, + delegator: impl Into, + height: Option, + offset: Option, + limit: Option, + ) -> dao_voting::delegation::DelegationsResponse { + self.querier() + .query_wasm_smart( + &self.delegation_addr, + &crate::msg::QueryMsg::Delegations { + delegator: delegator.into(), + height, + offset, + limit, + }, + ) + .unwrap() + } + + /// get the unvoted delegated voting power for a proposal + pub fn unvoted_delegated_voting_power( + &self, + delegate: impl Into, + proposal_module: impl Into, + proposal_id: u64, + start_height: u64, + ) -> dao_voting::delegation::UnvotedDelegatedVotingPowerResponse { + self.querier() + .query_wasm_smart( + &self.delegation_addr, + &crate::msg::QueryMsg::UnvotedDelegatedVotingPower { + delegate: delegate.into(), + proposal_module: proposal_module.into(), + proposal_id, + height: start_height, + }, + ) + .unwrap() + } + + /// get the proposal modules + pub fn proposal_modules(&self, start_after: Option, limit: Option) -> Vec { + self.querier() + .query_wasm_smart( + &self.delegation_addr, + &crate::msg::QueryMsg::ProposalModules { start_after, limit }, + ) + .unwrap() + } + + /// get the voting power hook callers + pub fn voting_power_hook_callers( + &self, + start_after: Option, + limit: Option, + ) -> Vec { + self.querier() + .query_wasm_smart( + &self.delegation_addr, + &crate::msg::QueryMsg::VotingPowerHookCallers { start_after, limit }, + ) + .unwrap() + } +} + +/// ASSERTIONS +impl DaoVoteDelegationTestingSuite { + /// assert that there are N delegations + pub fn assert_delegations_count(&self, delegator: impl Into, count: u32) { + let delegations = self.delegations(delegator, None, None, None); + assert_eq!(delegations.delegations.len() as u32, count); + } + + /// assert that there are N active delegations + pub fn assert_active_delegations_count(&self, delegator: impl Into, count: u32) { + let delegations = self.delegations(delegator, None, None, None); + assert_eq!( + delegations.delegations.iter().filter(|d| d.active).count() as u32, + count + ); + } + + /// assert that an active delegation exists + pub fn assert_delegation( + &self, + delegator: impl Into, + delegate: impl Into + Copy, + percent: Decimal, + ) { + let delegations = self.delegations(delegator, None, None, None); + assert!(delegations + .delegations + .iter() + .any(|d| d.delegate == delegate.into() && d.percent == percent && d.active)); + } + + /// assert that there are N delegates + pub fn assert_delegates_count(&self, count: u32) { + let delegates = self.delegates(None, None); + assert_eq!(delegates.len() as u32, count); + } + + /// assert a delegate is registered + pub fn assert_registered(&self, delegate: impl Into + Copy) { + let delegates = self.delegates(None, None); + assert!(delegates.iter().any(|d| d.delegate == delegate.into())); + } + + /// assert a delegate's total delegated voting power + pub fn assert_delegate_total_delegated_vp( + &self, + delegate: impl Into + Copy, + expected_total: impl Into, + ) { + let delegate_total = self + .delegates(None, None) + .into_iter() + .find(|d| d.delegate == delegate.into()) + .unwrap() + .power; + assert_eq!(delegate_total, expected_total.into()); + } + + /// assert a delegate's total UDVP on a proposal + pub fn assert_total_udvp( + &self, + delegate: impl Into, + proposal_module: impl Into, + proposal_id: u64, + start_height: u64, + total: impl Into, + ) { + let udvp = self.unvoted_delegated_voting_power( + delegate, + proposal_module, + proposal_id, + start_height, + ); + assert_eq!(udvp.total, total.into()); + } + + /// assert a delegate's effective UDVP on a proposal + pub fn assert_effective_udvp( + &self, + delegate: impl Into, + proposal_module: impl Into, + proposal_id: u64, + start_height: u64, + effective: impl Into, + ) { + let udvp = self.unvoted_delegated_voting_power( + delegate, + proposal_module, + proposal_id, + start_height, + ); + assert_eq!(udvp.effective, effective.into()); + } + + /// assert vote count on single choice proposal + pub fn assert_single_choice_votes_count( + &self, + proposal_module: impl Into, + proposal_id: u64, + vote: dao_voting::voting::Vote, + count: impl Into, + ) { + let proposal = self.get_single_choice_proposal(proposal_module, proposal_id); + assert_eq!(proposal.votes.get(vote), count.into()); + } +} diff --git a/contracts/delegation/dao-vote-delegation/src/testing/tests.rs b/contracts/delegation/dao-vote-delegation/src/testing/tests.rs index 9743cb017..9f41e3df5 100644 --- a/contracts/delegation/dao-vote-delegation/src/testing/tests.rs +++ b/contracts/delegation/dao-vote-delegation/src/testing/tests.rs @@ -1,7 +1,8 @@ -use cosmwasm_std::{Addr, Decimal, Empty, Uint128}; +use cosmwasm_std::{to_json_binary, Addr, Decimal, Empty, Uint128}; use cw_multi_test::{Contract, ContractWrapper}; -use dao_testing::{DaoTestingSuite, DaoTestingSuiteBase, ADDR0, ADDR1, ADDR2}; -use dao_voting::delegation::{DelegationResponse, DelegationsResponse}; +use dao_testing::{ADDR0, ADDR1, ADDR2, ADDR3, ADDR4}; + +use super::*; pub fn dao_vote_delegation_contract() -> Box> { let contract = ContractWrapper::new( @@ -14,144 +15,595 @@ pub fn dao_vote_delegation_contract() -> Box> { } #[test] -fn test_setup() { - let mut base = DaoTestingSuiteBase::base(); - let mut suite = base.cw4(); - let dao = suite.dao(); - - let code_id = suite.store(dao_vote_delegation_contract); - let delegation_addr = suite.instantiate( - code_id, - &dao.core_addr, - &crate::msg::InstantiateMsg { - dao: None, - vp_hook_callers: Some(vec![dao.x.group_addr.to_string()]), - no_sync_proposal_modules: None, - vp_cap_percent: Some(Decimal::percent(50)), - delegation_validity_blocks: Some(100), - }, - &[], - "delegation", - None, +fn test_simple() { + let mut suite = DaoVoteDelegationTestingSuite::new() + .with_vp_cap_percent(Decimal::percent(50)) + .with_delegation_validity_blocks(10) + .build(); + let dao = suite.dao.clone(); + + // ensure set up correctly + assert_eq!( + suite.voting_power_hook_callers(None, None), + vec![dao.x.group_addr.clone()] ); + assert_eq!( + suite.proposal_modules(None, None), + dao.proposal_modules + .iter() + .map(|p| p.1.clone()) + .collect::>() + ); + + // register ADDR0 as a delegate + suite.register(ADDR0); + suite.assert_delegates_count(1); + suite.assert_registered(ADDR0); - // register addr0 as a delegate - suite.execute_smart( + // delegate 100% of addr1's voting power to ADDR0 + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + + // delegations take effect on the next block + suite.advance_block(); + + suite.assert_delegations_count(ADDR1, 1); + suite.assert_delegation(ADDR1, ADDR0, Decimal::percent(100)); + suite.assert_delegate_total_delegated_vp(ADDR0, suite.members[1].weight); + + // propose a proposal + let (proposal_module, id1, p1) = + suite.propose_single_choice(&dao, ADDR0, "test proposal 1", vec![]); + + // ensure delegation is correctly applied to proposal + suite.assert_effective_udvp( ADDR0, - &delegation_addr, - &crate::msg::ExecuteMsg::Register {}, - &[], + &proposal_module, + id1, + p1.start_height, + suite.members[1].weight, + ); + suite.assert_total_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + suite.members[1].weight, ); - // delegate 100% of addr1's voting power to addr0 - suite.execute_smart( - ADDR1, - &delegation_addr, - &crate::msg::ExecuteMsg::Delegate { - delegate: ADDR0.to_string(), - percent: Decimal::percent(100), - }, - &[], + // set delegation to 50% + suite.delegate(ADDR1, ADDR0, Decimal::percent(50)); + + // delegations take effect on the next block + suite.advance_block(); + + suite.assert_delegation(ADDR1, ADDR0, Decimal::percent(50)); + suite.assert_delegate_total_delegated_vp(ADDR0, suite.members[1].weight / 2); + + // propose another proposal + let (_, id2, p2) = suite.propose_single_choice(&dao, ADDR2, "test proposal 2", vec![]); + + // ensure delegation is correctly applied to new proposal + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id2, + p2.start_height, + suite.members[1].weight / 2, ); + // ensure old delegation is still applied to old proposal + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + suite.members[1].weight, + ); + + // revoke delegation + suite.undelegate(ADDR1, ADDR0); + // delegations take effect on the next block suite.advance_block(); - let delegations: DelegationsResponse = suite - .querier() - .query_wasm_smart( - &delegation_addr, - &crate::msg::QueryMsg::Delegations { - delegator: ADDR1.to_string(), - height: None, - offset: None, - limit: None, - }, - ) - .unwrap(); - - assert_eq!(delegations.delegations.len(), 1); - assert_eq!( - delegations.delegations[0], - DelegationResponse { - delegate: Addr::unchecked(ADDR0), - percent: Decimal::percent(100), - active: true, + suite.assert_delegations_count(ADDR1, 0); + suite.assert_delegate_total_delegated_vp(ADDR0, 0u128); + + // propose another proposal + let (_, id3, p3) = suite.propose_single_choice(&dao, ADDR2, "test proposal 3", vec![]); + + // ensure delegation is removed from new proposal + suite.assert_effective_udvp(ADDR0, &proposal_module, id3, p3.start_height, 0u128); + suite.assert_total_udvp(ADDR0, &proposal_module, id3, p3.start_height, 0u128); + + // delegate 100% of every other member's voting power to ADDR0 + for member in suite.members.clone() { + if member.addr != ADDR0 { + suite.delegate(member.addr, ADDR0, Decimal::percent(100)); } + } + + // delegations take effect on the next block + suite.advance_block(); + + let total_vp_except_addr0 = suite + .members + .iter() + .map(|m| if m.addr == ADDR0 { 0 } else { m.weight as u128 }) + .sum::(); + suite.assert_delegate_total_delegated_vp(ADDR0, total_vp_except_addr0); + + // propose another proposal + let (_, id4, p4) = suite.propose_single_choice(&dao, ADDR0, "test proposal 4", vec![]); + + // ensure delegation is correctly applied to proposal and that VP cap is + // applied correctly. effective should be 50% of total voting power, and + // total should be everything that's delegated to ADDR0 + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id4, + p4.start_height, + // VP cap is set to 50% of total voting power + Uint128::from(suite.members.iter().map(|m| m.weight as u128).sum::()) + .mul_floor(Decimal::percent(50)), + ); + suite.assert_total_udvp( + ADDR0, + &proposal_module, + id4, + p4.start_height, + total_vp_except_addr0, ); + // advance 10 blocks to expire all delegations + suite.advance_blocks(10); + + suite.assert_delegate_total_delegated_vp(ADDR0, 0u128); + + // propose another proposal + let (_, id5, p5) = suite.propose_single_choice(&dao, ADDR0, "test proposal 5", vec![]); + + suite.assert_effective_udvp(ADDR0, &proposal_module, id5, p5.start_height, 0u128); + suite.assert_total_udvp(ADDR0, &proposal_module, id5, p5.start_height, 0u128); + + // delegate 100% of every other member's voting power to ADDR0 again + for member in suite.members.clone() { + if member.addr != ADDR0 { + suite.delegate(member.addr, ADDR0, Decimal::percent(100)); + } + } + + // delegations take effect on the next block + suite.advance_block(); + + suite.assert_delegate_total_delegated_vp(ADDR0, total_vp_except_addr0); + + // unregister ADDR0 as a delegate + suite.unregister(ADDR0); + + // delegations take effect on the next block + suite.advance_block(); + + suite.assert_delegates_count(0); + + // propose another proposal + let (_, id6, p6) = suite.propose_single_choice(&dao, ADDR0, "test proposal 6", vec![]); + + suite.assert_effective_udvp(ADDR0, &proposal_module, id6, p6.start_height, 0u128); + suite.assert_total_udvp(ADDR0, &proposal_module, id6, p6.start_height, 0u128); + + // ensure that ADDR1 has 1 delegation but 0 active delegations since their + // delegate unregistered + suite.assert_delegations_count(ADDR1, 1); + suite.assert_active_delegations_count(ADDR1, 0); +} + +#[test] +fn test_vp_cap_update() { + let mut suite = DaoVoteDelegationTestingSuite::new() + .with_vp_cap_percent(Decimal::percent(50)) + .with_delegation_validity_blocks(10) + .build(); + let dao = suite.dao.clone(); + + // register ADDR0 as a delegate + suite.register(ADDR0); + + // delegate 100% of every other member's voting power to ADDR0 + for member in suite.members.clone() { + if member.addr != ADDR0 { + suite.delegate(member.addr, ADDR0, Decimal::percent(100)); + } + } + + // delegations take effect on the next block + suite.advance_block(); + + let total_vp_except_addr0 = suite + .members + .iter() + .map(|m| if m.addr == ADDR0 { 0 } else { m.weight as u128 }) + .sum::(); + suite.assert_delegate_total_delegated_vp(ADDR0, total_vp_except_addr0); + // propose a proposal let (proposal_module, id1, p1) = - dao.propose_single_choice(&mut suite, ADDR0, "test proposal 1", vec![]); + suite.propose_single_choice(&dao, ADDR0, "test proposal", vec![]); - // ensure delegation is correctly applied to proposal - let udvp: dao_voting::delegation::UnvotedDelegatedVotingPowerResponse = suite - .querier() - .query_wasm_smart( - &delegation_addr, - &crate::msg::QueryMsg::UnvotedDelegatedVotingPower { - delegate: ADDR0.to_string(), - proposal_module: proposal_module.to_string(), - proposal_id: id1, - height: p1.start_height, - }, - ) - .unwrap(); + // ensure delegation is correctly applied to proposal and that VP cap is + // applied correctly. effective should be 50% of total voting power, and + // total should be everything that's delegated to ADDR0 + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + // VP cap is set to 50% of total voting power + Uint128::from(suite.members.iter().map(|m| m.weight as u128).sum::()) + .mul_floor(Decimal::percent(50)), + ); + suite.assert_total_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + total_vp_except_addr0, + ); + + // change VP cap to 30% of total + suite.update_vp_cap_percent(Some(Decimal::percent(30))); + // updates take effect on the next block + suite.advance_block(); + + // propose another proposal + let (_, id2, p2) = suite.propose_single_choice(&dao, ADDR0, "test proposal", vec![]); + + // ensure delegation is correctly applied to proposal and that VP cap is + // applied correctly. effective should be 30% of total voting power, and + // total should still be everything that's delegated to ADDR0 + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id2, + p2.start_height, + // VP cap is set to 30% of total voting power + Uint128::from(suite.members.iter().map(|m| m.weight as u128).sum::()) + .mul_floor(Decimal::percent(30)), + ); + suite.assert_total_udvp( + ADDR0, + &proposal_module, + id2, + p2.start_height, + total_vp_except_addr0, + ); + + // old proposal should still use old VP cap + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + // VP cap is set to 50% of total voting power + Uint128::from(suite.members.iter().map(|m| m.weight as u128).sum::()) + .mul_floor(Decimal::percent(50)), + ); + suite.assert_total_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + total_vp_except_addr0, + ); + + // remove VP cap + suite.update_vp_cap_percent(None); + // updates take effect on the next block + suite.advance_block(); + + // propose another proposal + let (_, id3, p3) = suite.propose_single_choice(&dao, ADDR0, "test proposal", vec![]); + + // effective should now be equal to total since there is no cap + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id3, + p3.start_height, + total_vp_except_addr0, + ); + suite.assert_total_udvp( + ADDR0, + &proposal_module, + id3, + p3.start_height, + total_vp_except_addr0, + ); + + // old proposals should still use old VP caps + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id2, + p2.start_height, + // VP cap is set to 30% of total voting power + Uint128::from(suite.members.iter().map(|m| m.weight as u128).sum::()) + .mul_floor(Decimal::percent(30)), + ); + suite.assert_total_udvp( + ADDR0, + &proposal_module, + id2, + p2.start_height, + total_vp_except_addr0, + ); + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + // VP cap is set to 50% of total voting power + Uint128::from(suite.members.iter().map(|m| m.weight as u128).sum::()) + .mul_floor(Decimal::percent(50)), + ); + suite.assert_total_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + total_vp_except_addr0, + ); +} + +#[test] +fn test_expiration_update() { + let mut suite = DaoVoteDelegationTestingSuite::new() + .with_delegation_validity_blocks(10) + .build(); + + // register ADDR0 as a delegate + suite.register(ADDR0); + + // delegate to ADDR0 + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + // delegations take effect on the next block + suite.advance_block(); + + suite.assert_delegations_count(ADDR1, 1); + suite.assert_delegation(ADDR1, ADDR0, Decimal::percent(100)); + suite.assert_delegate_total_delegated_vp(ADDR0, suite.members[1].weight); + + // update delegation validity blocks to 50 + suite.update_delegation_validity_blocks(Some(50)); + + // move 10 blocks into the future + suite.advance_blocks(10); + + // delegation should be expired after 10 blocks since update happened after + suite.assert_delegations_count(ADDR1, 0); + suite.assert_delegate_total_delegated_vp(ADDR0, 0u128); + + // delegate to ADDR0 + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + // delegations take effect on the next block + suite.advance_block(); + + // move 10 blocks into the future + suite.advance_blocks(10); + + // delegation should still be active + suite.assert_delegations_count(ADDR1, 1); + suite.assert_delegation(ADDR1, ADDR0, Decimal::percent(100)); + suite.assert_delegate_total_delegated_vp(ADDR0, suite.members[1].weight); + + // move 40 blocks into the future + suite.advance_blocks(40); + + // delegation should be expired + suite.assert_delegations_count(ADDR1, 0); + suite.assert_delegate_total_delegated_vp(ADDR0, 0u128); + + suite.advance_block(); + + // remove expiration + suite.update_delegation_validity_blocks(None); + + // delegate to ADDR0 + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + // delegations take effect on the next block + suite.advance_block(); + + // move 10 blocks into the future + suite.advance_blocks(10); + + // delegation should still be active + suite.assert_delegations_count(ADDR1, 1); + suite.assert_delegation(ADDR1, ADDR0, Decimal::percent(100)); + suite.assert_delegate_total_delegated_vp(ADDR0, suite.members[1].weight); + + // move 100 blocks into the future + suite.advance_blocks(100); + + // delegation should still be active + suite.assert_delegations_count(ADDR1, 1); + suite.assert_delegation(ADDR1, ADDR0, Decimal::percent(100)); + suite.assert_delegate_total_delegated_vp(ADDR0, suite.members[1].weight); +} + +#[test] +fn test_update_hook_callers() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + let dao = suite.dao.clone(); + + // ensure setup correctly + assert_eq!( + suite.voting_power_hook_callers(None, None), + vec![dao.x.group_addr.clone()] + ); assert_eq!( - udvp.effective, - Uint128::from(suite.members[1].weight as u128) + suite.proposal_modules(None, None), + dao.proposal_modules + .iter() + .map(|p| p.1.clone()) + .collect::>() ); - // set delegation to 50% - suite.execute_smart( - ADDR1, - &delegation_addr, - &crate::msg::ExecuteMsg::Delegate { - delegate: ADDR0.to_string(), - percent: Decimal::percent(50), + // add another contract as a voting power hook caller + suite.update_voting_power_hook_callers(Some(vec!["addr".to_string()]), None); + + assert_eq!( + suite.voting_power_hook_callers(None, None), + vec![Addr::unchecked("addr"), dao.x.group_addr.clone()] + ); + + // add another proposal module to the DAO + let proposal_sudo_code_id = suite.proposal_sudo_id; + suite.execute_smart_ok( + &dao.core_addr, + &dao.core_addr, + &dao_interface::msg::ExecuteMsg::UpdateProposalModules { + to_add: vec![dao_interface::state::ModuleInstantiateInfo { + code_id: proposal_sudo_code_id, + msg: to_json_binary(&dao_proposal_sudo::msg::InstantiateMsg { + root: "root".to_string(), + }) + .unwrap(), + admin: None, + label: "sudo".to_string(), + funds: vec![], + }], + to_disable: vec![], }, &[], ); + // sync proposal modules + suite.sync_proposal_modules(None, None); + + // ensure new proposal module is synced + assert_eq!( + suite.proposal_modules(None, None).len(), + dao.proposal_modules.len() + 1 + ); +} + +#[test] +fn test_vote_with_override() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + let dao = suite.dao.clone(); + + // register ADDR0 and ADDR3 as delegates + suite.register(ADDR0); + suite.register(ADDR3); + + // delegate all of ADDR1's and half of ADDR2's voting power to ADDR0 + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + suite.delegate(ADDR2, ADDR0, Decimal::percent(50)); + // delegate all of ADDR4's voting power to ADDR3 + suite.delegate(ADDR4, ADDR3, Decimal::percent(100)); + // delegations take effect on the next block suite.advance_block(); + // ensure delegations are correctly applied + suite.assert_delegations_count(ADDR1, 1); + suite.assert_delegations_count(ADDR2, 1); + suite.assert_delegations_count(ADDR4, 1); + // propose a proposal - let (_, id2, p2) = dao.propose_single_choice(&mut suite, ADDR2, "test proposal 2", vec![]); + let (proposal_module, id1, p1) = + suite.propose_single_choice(&dao, ADDR2, "test proposal", vec![]); - // ensure delegation is correctly applied to new proposal - let udvp: dao_voting::delegation::UnvotedDelegatedVotingPowerResponse = suite - .querier() - .query_wasm_smart( - &delegation_addr, - &crate::msg::QueryMsg::UnvotedDelegatedVotingPower { - delegate: ADDR0.to_string(), - proposal_module: proposal_module.to_string(), - proposal_id: id2, - height: p2.start_height, - }, - ) - .unwrap(); - assert_eq!( - udvp.effective, - Uint128::from((suite.members[1].weight / 2) as u128) + // ADDR0 has 100% of ADDR1's voting power and 50% of ADDR2's voting power + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + suite.members[1].weight + suite.members[2].weight / 2, + ); + // ADDR3 has 100% of ADDR4's voting power + suite.assert_effective_udvp( + ADDR3, + &proposal_module, + id1, + p1.start_height, + suite.members[4].weight, ); - // ensure old delegation is still applied to old proposal - let udvp: dao_voting::delegation::UnvotedDelegatedVotingPowerResponse = suite - .querier() - .query_wasm_smart( - &delegation_addr, - &crate::msg::QueryMsg::UnvotedDelegatedVotingPower { - delegate: ADDR0.to_string(), - proposal_module: proposal_module.to_string(), - proposal_id: id1, - height: p1.start_height, - }, - ) - .unwrap(); - assert_eq!( - udvp.effective, - Uint128::from(suite.members[1].weight as u128) + // delegate ADDR0 votes on proposal + suite.vote_single_choice(&dao, ADDR0, id1, dao_voting::voting::Vote::Yes); + + // ADDR0 votes with own voting power, 100% of ADDR1's voting power, and 50% + // of ADDR2's voting power + suite.assert_single_choice_votes_count( + &proposal_module, + id1, + dao_voting::voting::Vote::Yes, + suite.members[0].weight + suite.members[1].weight + suite.members[2].weight / 2, + ); + + // ADDR1 overrides ADDR0's vote + suite.vote_single_choice(&dao, ADDR1, id1, dao_voting::voting::Vote::No); + // ADDR0's unvoted delegated voting power should no longer include ADDR1's + // voting power on this proposal + suite.assert_effective_udvp( + ADDR0, + &proposal_module, + id1, + p1.start_height, + suite.members[2].weight / 2, + ); + // vote counts should change to reflect removed (overridden) delegate vote + suite.assert_single_choice_votes_count( + &proposal_module, + id1, + dao_voting::voting::Vote::Yes, + suite.members[0].weight + suite.members[2].weight / 2, + ); + suite.assert_single_choice_votes_count( + &proposal_module, + id1, + dao_voting::voting::Vote::No, + suite.members[1].weight, + ); + + // ADDR4 votes before their delegate ADDR3 does + suite.vote_single_choice(&dao, ADDR4, id1, dao_voting::voting::Vote::Abstain); + // ADDR3 unvoted delegated voting power should not include ADDR4's voting + // power anymore, meaning it's zero + suite.assert_effective_udvp(ADDR3, &proposal_module, id1, p1.start_height, 0u128); + // abstain should count ADDR4's voting power + suite.assert_single_choice_votes_count( + &proposal_module, + id1, + dao_voting::voting::Vote::Abstain, + suite.members[4].weight, + ); + + // ADDR3 votes + suite.vote_single_choice(&dao, ADDR3, id1, dao_voting::voting::Vote::No); + // no votes should only include ADDR3's voting power (and ADDR1 from + // before). ADDR4's delegated VP should not be counted here since they + // already voted + suite.assert_single_choice_votes_count( + &proposal_module, + id1, + dao_voting::voting::Vote::No, + suite.members[1].weight + suite.members[3].weight, + ); + + // ADDR2 overrides ADDR0's vote + suite.vote_single_choice(&dao, ADDR2, id1, dao_voting::voting::Vote::Yes); + // UDVP should now be zero for ADDR0 since all of their delegates overrode + // their votes. + suite.assert_effective_udvp(ADDR0, &proposal_module, id1, p1.start_height, 0u128); + // now yes should count all of ADDR0 and ADDR2's voting power + suite.assert_single_choice_votes_count( + &proposal_module, + id1, + dao_voting::voting::Vote::Yes, + suite.members[0].weight + suite.members[2].weight, ); } diff --git a/packages/dao-interface/src/helpers.rs b/packages/dao-interface/src/helpers.rs index 40ba76c2c..e15d325a5 100644 --- a/packages/dao-interface/src/helpers.rs +++ b/packages/dao-interface/src/helpers.rs @@ -8,7 +8,7 @@ pub enum Update { /// An update type that allows partial updates of optional fields. #[cw_serde] -pub struct OptionalUpdate(Option>); +pub struct OptionalUpdate(pub Option>); impl OptionalUpdate { /// Updates the value if it exists, otherwise does nothing. diff --git a/packages/dao-testing/src/suite/base.rs b/packages/dao-testing/src/suite/base.rs index 8a27e8e99..3db977430 100644 --- a/packages/dao-testing/src/suite/base.rs +++ b/packages/dao-testing/src/suite/base.rs @@ -5,7 +5,7 @@ use std::{ use cosmwasm_std::{to_json_binary, Addr, Coin, CosmosMsg, Empty, QuerierWrapper, Timestamp}; use cw20::Cw20Coin; -use cw_multi_test::{App, AppResponse, Contract, Executor}; +use cw_multi_test::{error::AnyResult, App, AppResponse, Contract, Executor}; use cw_utils::Duration; use serde::Serialize; @@ -22,58 +22,6 @@ pub struct TestDao { pub x: Extra, } -impl TestDao { - /// propose a single choice proposal and return the proposal module address, - /// proposal ID, and proposal - pub fn propose_single_choice( - &self, - base: &mut DaoTestingSuiteBase, - proposer: impl Into, - title: impl Into, - msgs: Vec, - ) -> ( - Addr, - u64, - dao_proposal_single::proposal::SingleChoiceProposal, - ) { - let pre_propose_msg = dao_pre_propose_single::ExecuteMsg::Propose { - msg: dao_pre_propose_single::ProposeMessage::Propose { - title: title.into(), - description: "".to_string(), - msgs, - vote: None, - }, - }; - - let (pre_propose_module, proposal_module) = &self.proposal_modules[0]; - - base.execute_smart( - proposer, - pre_propose_module.as_ref().unwrap(), - &pre_propose_msg, - &[], - ); - - let proposal_id: u64 = base - .querier() - .query_wasm_smart( - proposal_module.clone(), - &dao_proposal_single::msg::QueryMsg::ProposalCount {}, - ) - .unwrap(); - - let res: dao_proposal_single::query::ProposalResponse = base - .querier() - .query_wasm_smart( - proposal_module.clone(), - &dao_proposal_single::msg::QueryMsg::Proposal { proposal_id }, - ) - .unwrap(); - - (proposal_module.clone(), proposal_id, res.proposal) - } -} - pub struct DaoTestingSuiteBase { pub app: App, @@ -404,14 +352,14 @@ impl DaoTestingSuiteBase { // UTILITIES impl DaoTestingSuiteBase { - /// get the app querier - pub fn querier(&self) -> QuerierWrapper<'_> { - self.app.wrap() + /// advance the block height by N + pub fn advance_blocks(&mut self, n: u64) { + self.app.update_block(|b| b.height += n); } /// advance the block height by one pub fn advance_block(&mut self) { - self.app.update_block(|b| b.height += 1); + self.advance_blocks(1); } /// store a contract given its maker function and return its code ID @@ -436,26 +384,36 @@ impl DaoTestingSuiteBase { init_msg, send_funds, label.into(), - admin.map(|a| a.into()), + admin, ) .unwrap() } - /// execute a smart contract and expect it to succeed + /// execute a smart contract and return the result pub fn execute_smart( &mut self, sender: impl Into, contract_addr: impl Into, msg: &T, send_funds: &[Coin], + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender.into()), + Addr::unchecked(contract_addr.into()), + msg, + send_funds, + ) + } + + /// execute a smart contract and expect it to succeed + pub fn execute_smart_ok( + &mut self, + sender: impl Into, + contract_addr: impl Into, + msg: &T, + send_funds: &[Coin], ) -> AppResponse { - self.app - .execute_contract( - Addr::unchecked(sender.into()), - Addr::unchecked(contract_addr.into()), - msg, - send_funds, - ) + self.execute_smart(sender, contract_addr, msg, send_funds) .unwrap() } @@ -467,13 +425,7 @@ impl DaoTestingSuiteBase { msg: &T, send_funds: &[Coin], ) -> E { - self.app - .execute_contract( - Addr::unchecked(sender.into()), - Addr::unchecked(contract_addr.into()), - msg, - send_funds, - ) + self.execute_smart(sender, contract_addr, msg, send_funds) .unwrap_err() .downcast() .unwrap() @@ -497,4 +449,125 @@ impl DaoTestingSuiteBase { None, ) } + + /// propose a single choice proposal and return the proposal module address, + /// proposal ID, and proposal + pub fn propose_single_choice( + &mut self, + dao: &TestDao, + proposer: impl Into, + title: impl Into, + msgs: Vec, + ) -> ( + Addr, + u64, + dao_proposal_single::proposal::SingleChoiceProposal, + ) { + let pre_propose_msg = dao_pre_propose_single::ExecuteMsg::Propose { + msg: dao_pre_propose_single::ProposeMessage::Propose { + title: title.into(), + description: "".to_string(), + msgs, + vote: None, + }, + }; + + let (pre_propose_module, proposal_module) = &dao.proposal_modules[0]; + + self.execute_smart_ok( + proposer, + pre_propose_module.as_ref().unwrap(), + &pre_propose_msg, + &[], + ); + + let proposal_id: u64 = self + .querier() + .query_wasm_smart( + proposal_module.clone(), + &dao_proposal_single::msg::QueryMsg::ProposalCount {}, + ) + .unwrap(); + + let proposal = self.get_single_choice_proposal(proposal_module, proposal_id); + + (proposal_module.clone(), proposal_id, proposal) + } + + /// vote on a single choice proposal + pub fn vote_single_choice( + &mut self, + dao: &TestDao, + voter: impl Into, + proposal_id: u64, + vote: dao_voting::voting::Vote, + ) { + self.execute_smart_ok( + voter, + &dao.proposal_modules[0].1, + &dao_proposal_single::msg::ExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ); + } + + /// add vote hook to all proposal modules + pub fn add_vote_hook(&mut self, dao: &TestDao, addr: impl Into) { + let address = addr.into(); + dao.proposal_modules + .iter() + .for_each(|(_, proposal_module)| { + self.execute_smart_ok( + dao.core_addr.clone(), + proposal_module.clone(), + &dao_proposal_single::msg::ExecuteMsg::AddVoteHook { + address: address.clone(), + }, + &[], + ); + }); + } + + /// set the delegation module for all proposal modules + pub fn set_delegation_module(&mut self, dao: &TestDao, module: impl Into) { + let module = module.into(); + dao.proposal_modules + .iter() + .for_each(|(_, proposal_module)| { + self.execute_smart_ok( + dao.core_addr.clone(), + proposal_module.clone(), + &dao_proposal_single::msg::ExecuteMsg::UpdateDelegationModule { + module: module.clone(), + }, + &[], + ); + }); + } +} + +/// QUERIES +impl DaoTestingSuiteBase { + /// get the app querier + pub fn querier(&self) -> QuerierWrapper<'_> { + self.app.wrap() + } + + /// get a single choice proposal + pub fn get_single_choice_proposal( + &self, + proposal_module: impl Into, + proposal_id: u64, + ) -> dao_proposal_single::proposal::SingleChoiceProposal { + self.querier() + .query_wasm_smart::( + Addr::unchecked(proposal_module), + &dao_proposal_single::msg::QueryMsg::Proposal { proposal_id }, + ) + .unwrap() + .proposal + } } diff --git a/packages/dao-testing/src/suite/cw20_suite.rs b/packages/dao-testing/src/suite/cw20_suite.rs index 6e2931362..4cbdd6667 100644 --- a/packages/dao-testing/src/suite/cw20_suite.rs +++ b/packages/dao-testing/src/suite/cw20_suite.rs @@ -27,13 +27,13 @@ impl<'a> Deref for DaoTestingSuiteCw20<'a> { type Target = DaoTestingSuiteBase; fn deref(&self) -> &Self::Target { - &self.base + self.base } } impl<'a> DerefMut for DaoTestingSuiteCw20<'a> { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.base + self.base } } @@ -103,7 +103,7 @@ impl<'a> DaoTestingSuiteCw20<'a> { staker: impl Into, amount: impl Into, ) { - self.execute_smart( + self.execute_smart_ok( staker, &dao.x.cw20_addr, &cw20::Cw20ExecuteMsg::Send { @@ -122,7 +122,7 @@ impl<'a> DaoTestingSuiteCw20<'a> { staker: impl Into, amount: impl Into, ) { - self.execute_smart( + self.execute_smart_ok( staker, &dao.x.staking_addr, &cw20_stake::msg::ExecuteMsg::Unstake { diff --git a/packages/dao-testing/src/suite/cw4_suite.rs b/packages/dao-testing/src/suite/cw4_suite.rs index 2910de7a7..343ac2a83 100644 --- a/packages/dao-testing/src/suite/cw4_suite.rs +++ b/packages/dao-testing/src/suite/cw4_suite.rs @@ -21,13 +21,13 @@ impl<'a> Deref for DaoTestingSuiteCw4<'a> { type Target = DaoTestingSuiteBase; fn deref(&self) -> &Self::Target { - &self.base + self.base } } impl<'a> DerefMut for DaoTestingSuiteCw4<'a> { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.base + self.base } } diff --git a/packages/dao-testing/src/suite/cw721_suite.rs b/packages/dao-testing/src/suite/cw721_suite.rs index 5ab18de7c..6fab1bbdf 100644 --- a/packages/dao-testing/src/suite/cw721_suite.rs +++ b/packages/dao-testing/src/suite/cw721_suite.rs @@ -31,13 +31,13 @@ impl<'a> Deref for DaoTestingSuiteCw721<'a> { type Target = DaoTestingSuiteBase; fn deref(&self) -> &Self::Target { - &self.base + self.base } } impl<'a> DerefMut for DaoTestingSuiteCw721<'a> { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.base + self.base } } @@ -98,7 +98,7 @@ impl<'a> DaoTestingSuiteCw721<'a> { staker: impl Into, token_id: impl Into, ) { - self.execute_smart( + self.execute_smart_ok( staker, &dao.x.cw721_addr, &cw721_base::msg::ExecuteMsg::::SendNft { @@ -117,7 +117,7 @@ impl<'a> DaoTestingSuiteCw721<'a> { staker: impl Into, token_id: impl Into, ) { - self.execute_smart( + self.execute_smart_ok( staker, &dao.voting_module_addr, &dao_voting_cw721_staked::msg::ExecuteMsg::Unstake { diff --git a/packages/dao-testing/src/suite/token_suite.rs b/packages/dao-testing/src/suite/token_suite.rs index 7dd0733d8..28513c85a 100644 --- a/packages/dao-testing/src/suite/token_suite.rs +++ b/packages/dao-testing/src/suite/token_suite.rs @@ -26,13 +26,13 @@ impl<'a> Deref for DaoTestingSuiteToken<'a> { type Target = DaoTestingSuiteBase; fn deref(&self) -> &Self::Target { - &self.base + self.base } } impl<'a> DerefMut for DaoTestingSuiteToken<'a> { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.base + self.base } } @@ -110,7 +110,7 @@ impl<'a> DaoTestingSuiteToken<'a> { staker: impl Into, amount: impl Into, ) { - self.execute_smart( + self.execute_smart_ok( staker, &dao.voting_module_addr, &dao_voting_token_staked::msg::ExecuteMsg::Stake {}, @@ -125,7 +125,7 @@ impl<'a> DaoTestingSuiteToken<'a> { staker: impl Into, amount: impl Into, ) { - self.execute_smart( + self.execute_smart_ok( staker, &dao.voting_module_addr, &dao_voting_token_staked::msg::ExecuteMsg::Unstake { diff --git a/packages/dao-voting/src/voting.rs b/packages/dao-voting/src/voting.rs index 5cbca1eed..d300f0072 100644 --- a/packages/dao-voting/src/voting.rs +++ b/packages/dao-voting/src/voting.rs @@ -190,6 +190,15 @@ impl Votes { pub fn total(&self) -> Uint128 { self.yes + self.no + self.abstain } + + /// Returns the number of votes for a given vote option. + pub fn get(&self, vote: Vote) -> Uint128 { + match vote { + Vote::Yes => self.yes, + Vote::No => self.no, + Vote::Abstain => self.abstain, + } + } } impl std::fmt::Display for Vote { From bb77bd018124f0ef838120b8764ff94db335a762 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 18 Oct 2024 12:01:04 -0400 Subject: [PATCH 18/24] mooooore tests --- .../dao-vote-delegation/src/contract.rs | 15 +- .../dao-vote-delegation/src/error.rs | 11 +- .../dao-vote-delegation/src/testing/suite.rs | 2 +- .../dao-vote-delegation/src/testing/tests.rs | 256 +++++++++++++++++- packages/dao-testing/src/suite/base.rs | 41 +++ 5 files changed, 306 insertions(+), 19 deletions(-) diff --git a/contracts/delegation/dao-vote-delegation/src/contract.rs b/contracts/delegation/dao-vote-delegation/src/contract.rs index 037ebea14..de4705051 100644 --- a/contracts/delegation/dao-vote-delegation/src/contract.rs +++ b/contracts/delegation/dao-vote-delegation/src/contract.rs @@ -35,7 +35,7 @@ use crate::state::{ }; use crate::ContractError; -pub(crate) const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-vote-delegation"; pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const DEFAULT_LIMIT: u32 = 10; @@ -149,7 +149,7 @@ fn execute_register(deps: DepsMut, env: Env, info: MessageInfo) -> Result Decimal::one() { return Err(ContractError::CannotDelegateMoreThan100Percent { current: current_percent_delegated - .checked_mul(Decimal::new(100u128.into()))? + .checked_mul(Decimal::from_atomics(100u128, 0).unwrap())? .to_string(), attempt: new_total_percent_delegated - .checked_mul(Decimal::new(100u128.into()))? + .checked_mul(Decimal::from_atomics(100u128, 0).unwrap())? .to_string(), }); } diff --git a/contracts/delegation/dao-vote-delegation/src/error.rs b/contracts/delegation/dao-vote-delegation/src/error.rs index 6e614f1d0..e46814f2c 100644 --- a/contracts/delegation/dao-vote-delegation/src/error.rs +++ b/contracts/delegation/dao-vote-delegation/src/error.rs @@ -40,15 +40,12 @@ pub enum ContractError { #[error("delegates cannot delegate to others")] DelegatesCannotDelegate {}, - #[error("undelegate before registering as a delegate")] - UndelegateBeforeRegistering {}, + #[error("cannot register as a delegate with existing delegations")] + CannotRegisterWithDelegations {}, - #[error("no voting power to delegate")] + #[error("no voting power")] NoVotingPower {}, - #[error("cannot delegate to self")] - CannotDelegateToSelf {}, - #[error("delegation does not exist")] DelegationDoesNotExist {}, @@ -58,7 +55,7 @@ pub enum ContractError { #[error("invalid voting power percent")] InvalidVotingPowerPercent {}, - #[error("migration error: incorrect contract: expected {expected}, actual {actual}")] + #[error("migration error: incorrect contract: expected \"{expected}\", actual \"{actual}\"")] MigrationErrorIncorrectContract { expected: String, actual: String }, #[error("migration error: invalid version: new {new}, current {current}")] diff --git a/contracts/delegation/dao-vote-delegation/src/testing/suite.rs b/contracts/delegation/dao-vote-delegation/src/testing/suite.rs index 72e91b953..bbf14657e 100644 --- a/contracts/delegation/dao-vote-delegation/src/testing/suite.rs +++ b/contracts/delegation/dao-vote-delegation/src/testing/suite.rs @@ -95,7 +95,7 @@ impl DaoVoteDelegationTestingSuite { }, &[], "delegation", - None, + Some(core_addr.to_string()), ); self.setup_delegation_module(); diff --git a/contracts/delegation/dao-vote-delegation/src/testing/tests.rs b/contracts/delegation/dao-vote-delegation/src/testing/tests.rs index 9f41e3df5..3d5b04de4 100644 --- a/contracts/delegation/dao-vote-delegation/src/testing/tests.rs +++ b/contracts/delegation/dao-vote-delegation/src/testing/tests.rs @@ -1,7 +1,13 @@ -use cosmwasm_std::{to_json_binary, Addr, Decimal, Empty, Uint128}; +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env}, + to_json_binary, Addr, Decimal, Empty, Uint128, +}; use cw_multi_test::{Contract, ContractWrapper}; +use dao_interface::helpers::OptionalUpdate; use dao_testing::{ADDR0, ADDR1, ADDR2, ADDR3, ADDR4}; +use crate::contract::{CONTRACT_NAME, CONTRACT_VERSION}; + use super::*; pub fn dao_vote_delegation_contract() -> Box> { @@ -607,3 +613,251 @@ fn test_vote_with_override() { suite.members[0].weight + suite.members[2].weight, ); } + +#[test] +fn test_allow_register_after_unregister_same_block() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.unregister(ADDR0); + suite.register(ADDR0); +} + +#[test] +fn test_allow_register_after_unregister_next_block() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.advance_block(); + suite.unregister(ADDR0); + suite.advance_block(); + suite.register(ADDR0); +} + +#[test] +#[should_panic(expected = "invalid delegation validity blocks: provided 1, minimum 2")] +fn test_validate_delegation_validity_blocks() { + DaoVoteDelegationTestingSuite::new() + .with_delegation_validity_blocks(1) + .build(); +} + +#[test] +#[should_panic(expected = "invalid delegation validity blocks: provided 1, minimum 2")] +fn test_validate_delegation_validity_blocks_update() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.update_delegation_validity_blocks(Some(1)); +} + +#[test] +#[should_panic(expected = "delegate already registered")] +fn test_no_double_register() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.register(ADDR0); +} + +#[test] +#[should_panic(expected = "no voting power")] +fn test_no_vp_register() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register("non_member"); +} + +#[test] +#[should_panic(expected = "cannot register as a delegate with existing delegations")] +fn test_cannot_register_with_delegations_same_block() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + suite.register(ADDR1); +} + +#[test] +#[should_panic(expected = "cannot register as a delegate with existing delegations")] +fn test_cannot_register_with_delegations_next_block() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + suite.advance_block(); + suite.register(ADDR1); +} + +#[test] +#[should_panic(expected = "delegate not registered")] +fn test_cannot_unregister_unregistered() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.unregister(ADDR0); +} + +#[test] +#[should_panic(expected = "invalid voting power percent")] +fn test_cannot_delegate_zero_percent() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.delegate(ADDR1, ADDR0, Decimal::zero()); +} + +#[test] +#[should_panic(expected = "invalid voting power percent")] +fn test_cannot_delegate_more_than_100_percent() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.delegate(ADDR1, ADDR0, Decimal::percent(101)); +} + +#[test] +#[should_panic(expected = "delegates cannot delegate to others")] +fn test_delegates_cannot_delegate() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.register(ADDR1); + suite.delegate(ADDR0, ADDR1, Decimal::percent(100)); +} + +#[test] +#[should_panic(expected = "delegate not registered")] +fn test_cannot_delegate_unregistered() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.delegate(ADDR0, ADDR1, Decimal::percent(100)); +} + +#[test] +#[should_panic(expected = "no voting power")] +fn test_cannot_delegate_no_vp() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.delegate("not_member", ADDR0, Decimal::percent(100)); +} + +#[test] +#[should_panic(expected = "cannot delegate more than 100% (current: 50%, attempt: 101%)")] +fn test_cannot_delegate_more_than_100() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.register(ADDR1); + suite.delegate(ADDR2, ADDR0, Decimal::percent(50)); + suite.delegate(ADDR2, ADDR1, Decimal::percent(51)); +} + +#[test] +#[should_panic(expected = "delegation does not exist")] +fn test_cannot_undelegate_nonexistent() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.undelegate(ADDR0, ADDR1); +} + +#[test] +fn test_delegate_undelegate_same_block() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + suite.undelegate(ADDR1, ADDR0); +} + +#[test] +#[should_panic(expected = "delegation does not exist")] +fn test_cannot_undelegate_twice() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + + suite.register(ADDR0); + suite.delegate(ADDR1, ADDR0, Decimal::percent(100)); + suite.undelegate(ADDR1, ADDR0); + suite.undelegate(ADDR1, ADDR0); +} + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_unauthorized_update_voting_power_hook_callers() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + let delegation_addr = suite.delegation_addr.clone(); + + suite.execute_smart_ok( + "no_one", + &delegation_addr, + &crate::msg::ExecuteMsg::UpdateVotingPowerHookCallers { + add: None, + remove: None, + }, + &[], + ); +} + +#[test] +#[should_panic(expected = "unauthorized")] +fn test_unauthorized_config_update() { + let mut suite = DaoVoteDelegationTestingSuite::new().build(); + let delegation_addr = suite.delegation_addr.clone(); + + suite.execute_smart_ok( + "no_one", + &delegation_addr, + &crate::msg::ExecuteMsg::UpdateConfig { + vp_cap_percent: OptionalUpdate(None), + delegation_validity_blocks: OptionalUpdate(None), + }, + &[], + ); +} + +#[test] +fn test_migration_incorrect_contract() { + let mut deps = mock_dependencies(); + + cw2::set_contract_version(&mut deps.storage, "different_contract", "0.1.0").unwrap(); + + let err = + crate::contract::migrate(deps.as_mut(), mock_env(), crate::msg::MigrateMsg {}).unwrap_err(); + assert_eq!( + err, + crate::ContractError::MigrationErrorIncorrectContract { + expected: "crates.io:dao-vote-delegation".to_string(), + actual: "different_contract".to_string(), + } + ); +} + +#[test] +fn test_cannot_migrate_to_same_version() { + let mut deps = mock_dependencies(); + + cw2::set_contract_version(&mut deps.storage, CONTRACT_NAME, CONTRACT_VERSION).unwrap(); + + let err = + crate::contract::migrate(deps.as_mut(), mock_env(), crate::msg::MigrateMsg {}).unwrap_err(); + assert_eq!( + err, + crate::ContractError::MigrationErrorInvalidVersion { + new: CONTRACT_VERSION.to_string(), + current: CONTRACT_VERSION.to_string() + } + ); +} + +#[test] +fn test_migrate() { + let mut deps = mock_dependencies(); + + cw2::set_contract_version(&mut deps.storage, CONTRACT_NAME, "2.4.0").unwrap(); + + crate::contract::migrate(deps.as_mut(), mock_env(), crate::msg::MigrateMsg {}).unwrap(); + + let version = cw2::get_contract_version(&deps.storage).unwrap(); + + assert_eq!(version.contract, CONTRACT_NAME); + assert_eq!(version.version, CONTRACT_VERSION); +} diff --git a/packages/dao-testing/src/suite/base.rs b/packages/dao-testing/src/suite/base.rs index 3db977430..94d731fa4 100644 --- a/packages/dao-testing/src/suite/base.rs +++ b/packages/dao-testing/src/suite/base.rs @@ -431,6 +431,47 @@ impl DaoTestingSuiteBase { .unwrap() } + /// migrate a smart contract and return the result + pub fn migrate( + &mut self, + sender: impl Into, + contract_addr: impl Into, + msg: &T, + code_id: u64, + ) -> AnyResult { + self.app.migrate_contract( + Addr::unchecked(sender), + Addr::unchecked(contract_addr), + msg, + code_id, + ) + } + + /// migrate a smart contract and expect it to succeed + pub fn migrate_ok( + &mut self, + sender: impl Into, + contract_addr: impl Into, + msg: &T, + code_id: u64, + ) -> AppResponse { + self.migrate(sender, contract_addr, msg, code_id).unwrap() + } + + /// migrate a smart contract and return the error + pub fn migrate_err( + &mut self, + sender: impl Into, + contract_addr: impl Into, + msg: &T, + code_id: u64, + ) -> E { + self.migrate(sender, contract_addr, msg, code_id) + .unwrap_err() + .downcast() + .unwrap() + } + /// instantiate a cw20 contract and return its address pub fn instantiate_cw20(&mut self, name: &str, initial_balances: Vec) -> Addr { self.instantiate( From f3c101e827368c48cf5520233f9b576484683049 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 18 Oct 2024 12:41:04 -0400 Subject: [PATCH 19/24] free disk space in other github actions --- .github/workflows/release-contracts.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/release-contracts.yml b/.github/workflows/release-contracts.yml index 920b1c920..6ad5b041f 100644 --- a/.github/workflows/release-contracts.yml +++ b/.github/workflows/release-contracts.yml @@ -18,6 +18,17 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + docker-images: true + swap-storage: true + # tar is required for cargo cache - run: apk add --no-cache tar From 35ad3c1259f1f8968684b6d6d6245cde928abc58 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 31 Oct 2024 02:55:45 -0400 Subject: [PATCH 20/24] fixed renamed variable --- .../src/testing/tests.rs | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs index f02a17a98..eac9a36a5 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -2674,22 +2674,22 @@ fn test_stake_during_interval() { // after half the duration, half the rewards (50) should be distributed. suite.skip_blocks(50); - // MEMBER1 has 50% voting power, so should receive 50% of the rewards. - suite.assert_pending_rewards(MEMBER1, 1, 25); + // ADDR0 has 50% voting power, so should receive 50% of the rewards. + suite.assert_pending_rewards(ADDR0, 1, 25); - // change voting power before the next distribution interval. MEMBER1 now + // change voting power before the next distribution interval. ADDR0 now // has 80% voting power, an increase from 50%. - suite.mint_native(coin(300, GOV_DENOM), MEMBER1); - suite.stake_native_tokens(MEMBER1, 300); + suite.mint_native(coin(300, GOV_DENOM), ADDR0); + suite.stake_native_tokens(ADDR0, 300); // after the rest of the initial duration, they should earn rewards at the // increased rate (50 more tokens, and they own 80% of them). 25 + 40 = 65 suite.skip_blocks(50); - suite.assert_pending_rewards(MEMBER1, 1, 65); + suite.assert_pending_rewards(ADDR0, 1, 65); // after 50 more blocks from VP change, there are 40 more rewards. suite.skip_blocks(50); - suite.assert_pending_rewards(MEMBER1, 1, 105); + suite.assert_pending_rewards(ADDR0, 1, 105); } #[test] @@ -2711,28 +2711,28 @@ fn test_stake_on_edges_of_interval() { // after half the duration, half the rewards (50) should be distributed. suite.skip_blocks(50); - // MEMBER1 has 50% voting power, so should receive 50% of the rewards. - suite.assert_pending_rewards(MEMBER1, 1, 25); + // ADDR0 has 50% voting power, so should receive 50% of the rewards. + suite.assert_pending_rewards(ADDR0, 1, 25); // after the full duration, all the rewards (50) should be distributed. suite.skip_blocks(50); - // MEMBER1 has 50% voting power, so should receive 50% of the rewards. - suite.assert_pending_rewards(MEMBER1, 1, 50); + // ADDR0 has 50% voting power, so should receive 50% of the rewards. + suite.assert_pending_rewards(ADDR0, 1, 50); // change voting power right at the end of the distribution interval. - // MEMBER1 now has 80% voting power, an increase from 50%. - suite.mint_native(coin(300, GOV_DENOM), MEMBER1); - suite.stake_native_tokens(MEMBER1, 300); + // ADDR0 now has 80% voting power, an increase from 50%. + suite.mint_native(coin(300, GOV_DENOM), ADDR0); + suite.stake_native_tokens(ADDR0, 300); // after another interval, they should earn rewards at the increased rate // (50 more tokens, and they own 80% of them). 50 + 40 = 90 suite.skip_blocks(50); - suite.assert_pending_rewards(MEMBER1, 1, 90); + suite.assert_pending_rewards(ADDR0, 1, 90); // after 50 more blocks from VP change, there are 40 more rewards. suite.skip_blocks(50); - suite.assert_pending_rewards(MEMBER1, 1, 130); + suite.assert_pending_rewards(ADDR0, 1, 130); } #[test] From 8abe91549f4d3f38f3e9df6039d2856bf016b18b Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Thu, 31 Oct 2024 09:41:34 -0400 Subject: [PATCH 21/24] updated schema --- .../dao-vote-delegation/schema/dao-vote-delegation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json index 579c96924..5b1a276df 100644 --- a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json +++ b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json @@ -1,6 +1,6 @@ { "contract_name": "dao-vote-delegation", - "contract_version": "2.5.0", + "contract_version": "2.5.1", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", From b7efa1522980f9086c4c2fe05c0d009314396a6c Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 1 Nov 2024 14:10:15 -0400 Subject: [PATCH 22/24] updated schema --- .../dao-vote-delegation/schema/dao-vote-delegation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json index 5b1a276df..bce7dcbb0 100644 --- a/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json +++ b/contracts/delegation/dao-vote-delegation/schema/dao-vote-delegation.json @@ -1,6 +1,6 @@ { "contract_name": "dao-vote-delegation", - "contract_version": "2.5.1", + "contract_version": "2.6.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", From 56920aade38fca797e0a39794a6c164d9c0d69cc Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Fri, 1 Nov 2024 14:21:23 -0400 Subject: [PATCH 23/24] added missing fields in tests --- .../pre-propose/dao-pre-propose-approval-multiple/src/tests.rs | 3 +++ .../pre-propose/dao-pre-propose-approver/src/tests/multiple.rs | 2 ++ 2 files changed, 5 insertions(+) diff --git a/contracts/pre-propose/dao-pre-propose-approval-multiple/src/tests.rs b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/tests.rs index 308f781b1..85aa629d9 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-multiple/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-approval-multiple/src/tests.rs @@ -93,6 +93,7 @@ fn get_default_proposal_module_instantiate( }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } } @@ -1794,6 +1795,7 @@ fn test_instantiate_with_zero_native_deposit() { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } }; @@ -1863,6 +1865,7 @@ fn test_instantiate_with_zero_cw20_deposit() { }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } }; diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/tests/multiple.rs b/contracts/pre-propose/dao-pre-propose-approver/src/tests/multiple.rs index 1cc637182..fcce347ba 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/src/tests/multiple.rs +++ b/contracts/pre-propose/dao-pre-propose-approver/src/tests/multiple.rs @@ -133,6 +133,7 @@ fn get_proposal_module_approval_multiple_instantiate( }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } } @@ -166,6 +167,7 @@ fn get_proposal_module_approver_instantiate( }, close_proposal_on_execution_failure: false, veto: None, + delegation_module: None, } } From 590eaf01afaf87169e9583330dc59a5b3dc31e19 Mon Sep 17 00:00:00 2001 From: Noah Saso Date: Sun, 17 Nov 2024 11:58:12 -0500 Subject: [PATCH 24/24] changed state key --- contracts/delegation/dao-vote-delegation/src/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/delegation/dao-vote-delegation/src/state.rs b/contracts/delegation/dao-vote-delegation/src/state.rs index df3a029b9..f1b2b36c6 100644 --- a/contracts/delegation/dao-vote-delegation/src/state.rs +++ b/contracts/delegation/dao-vote-delegation/src/state.rs @@ -29,7 +29,7 @@ pub const DAO: Item = Item::new("dao"); /// the active proposal modules loaded from the DAO that can execute /// proposal-related hooks. -pub const PROPOSAL_HOOK_CALLERS: Map = Map::new("dpm"); +pub const PROPOSAL_HOOK_CALLERS: Map = Map::new("phc"); /// the contracts that can execute the voting power change hooks. these should /// be DAO voting modules or their associated staking contracts.