From 716be31189bc8da58c5f07b9fc4bd8e4d062d9c7 Mon Sep 17 00:00:00 2001 From: Jakub Bogucki Date: Wed, 9 Nov 2022 12:48:45 +0100 Subject: [PATCH 01/12] Update to v1.2.1 --- contracts/gauge/.cargo/config | 4 + contracts/gauge/Cargo.toml | 26 +++++ contracts/gauge/README.md | 92 +++++++++++++++ contracts/gauge/src/bin/schema.rs | 11 ++ contracts/gauge/src/contract.rs | 41 +++++++ contracts/gauge/src/error.rs | 13 +++ contracts/gauge/src/helpers.rs | 27 +++++ contracts/gauge/src/lib.rs | 7 ++ contracts/gauge/src/msg.rs | 187 ++++++++++++++++++++++++++++++ contracts/gauge/src/state.rs | 161 +++++++++++++++++++++++++ 10 files changed, 569 insertions(+) create mode 100644 contracts/gauge/.cargo/config create mode 100644 contracts/gauge/Cargo.toml create mode 100644 contracts/gauge/README.md create mode 100644 contracts/gauge/src/bin/schema.rs create mode 100644 contracts/gauge/src/contract.rs create mode 100644 contracts/gauge/src/error.rs create mode 100644 contracts/gauge/src/helpers.rs create mode 100644 contracts/gauge/src/lib.rs create mode 100644 contracts/gauge/src/msg.rs create mode 100644 contracts/gauge/src/state.rs diff --git a/contracts/gauge/.cargo/config b/contracts/gauge/.cargo/config new file mode 100644 index 000000000..af5698e58 --- /dev/null +++ b/contracts/gauge/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/gauge/Cargo.toml b/contracts/gauge/Cargo.toml new file mode 100644 index 000000000..0aa153a32 --- /dev/null +++ b/contracts/gauge/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "gauge-orchestrator" +version = { workspace = true } +authors = [ "Cosmorama " ] +edition = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +wynd-stake = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/contracts/gauge/README.md b/contracts/gauge/README.md new file mode 100644 index 000000000..953db48e7 --- /dev/null +++ b/contracts/gauge/README.md @@ -0,0 +1,92 @@ +# Gauge Orchestrator Contract + +There are many places where we want something like a [gauge contract](https://resources.curve.fi/reward-gauges/gauge-weights), +when we need to select a weighted group out of a larger group of options. For example, we will need one to +select the validators that the staking derivatives delegates to, and relative percentages to which one. +We will need one to select which AMM Pools should receive how many incentives (both JUNO and possibly WYND). +We may need one to assign incentives on our lending protocol. + +## Orchestrator + +To work properly, the gauge must be informed every time that the voting power of a member changes. +It does so by listening to "update hooks" on the underlying staking contract and if an address's +voting power changes, updating their vote weight in the gauge, and the tally for the option they +had voted for (if any). + +Every contract call has some overhead, which is silently added to the basic staking action. +If we have 5 gauges in WYND DAO, we would likely have a minimum of 5 x 65k or 325k gas per staking action, +just to update gauges. This is a lot of overhead, and we want to avoid it. + +To do so, we make one "Gauge Orchestrator", which can manage many different gauges. They all have the +same voting logic and rules to update when the voting power changes. The Orchestrator is the only +contract that must be called by the staking contract, and doing a few writes for each gauge is a +lot cheaper gas-wise than calling a separate contract. + +The Orchestrator has an "owner" (the WYND DAO) which is responsible for adding new gauges here, +and eventually stopping them if we don't need them anymore (to avoid extra writes). + +## Gauge Functionality + +A gauge is initialised with a set of options. Anyone with voting power may vote for any option at any time, +which is recorded, and also updates the tally. If they revote, it checks their last vote to reduce power on +that before adding to the new one. When an "update hook" is triggered, it updates the voting power +of that user's vote, while maintaining the same option. Either increasing or decreasing the tally +for the given option as appropriate. + +Every epoch (eg 1/week), the current tally of the gauge is sampled, and some cut-off applies +(top 20, min 0.5% of votes, etc). The resulting set is the "selected set" and the options along with +their relative vote counts (normalised to 1.0 = total votes within this set) is used to initiate some +action (eg. distribute reward tokens). + +## Extensibility + +We will be using one Orchestrator for many different gauges that update many different contracts. +To make it more extensible, we define option as an arbitrary string that makes sense to that contract. +We also store the integration logic in an external contract, called a `GaugeAdapter` that must provide +3 queries to the Orchestrator: + +* Provide set of all options: maybe expensive, iterate over all and return them. This is used for initialization. +* Check an option: Allow anyone to propose one, and this confirms if it is valid (eg is this a valid address + of a registered AMM pool?) +* Create update messages: Accepts "selected set" as argument, returns `Vec` to be executed by the + gauge contract / DAO. + +### Adapters + +We will create a mock implementation of an Adapter for testing. + +In production, we will need one for that queries an AMM Factory for open pools, +and knows how to send the rewards to the appropriate pools. + +We will need another for a JUNO staking derivative, to select which validators should +be in the set, and then upon execute, inform the contract to delegate to those validators. + +We would need a modified one, that uses IBC packets, for a remote staking derivative. +It would need to query the remote chain for the set of validators. And the messages +would be ICA Packets to transmit the new set to the remote chain. + +As you can see, it should be a quite flexible design, while keeping the tallying logic +centralized here and minimal gas impact on the staking contract to track the multiple gauges.s + +## Example Use + +When the DAO wants to add another gauge, it first uploads the code for generating eg. AMM reward messages, +and instantiates a properly configured Adapter. Then, it votes to create a new Gauge that uses this adapter. +Upon creating the gauge, it will query the adapter for the current set of options to initialize state. + +After one epoch has passed, anyone can trigger `Execute` on this gauge ID, and the Orchestrator will +apply the logic to determine the "selected set". It will then query the adapter for the messages +needed to convert that selection into the appropriate action, and it will send those to the +[WYND DAO core module](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#the-core-module) +to be executed. + +## Storage + +Every gauge that is created is given a new auto-incrementing ID. + +All non-global state in the contract (only owner and voting power contract) is indexed +first by the gauge and then by the other key (eg. voter address for Votes, option for tallied power, etc) + +We do not know how many gauges will be there a priori and this composite index allows us to +be flexible. Not the use of `.prefix()` and `.sub_prefix()` in `state.rs` tests to efficiently +focus on the relevant data for one gauge. diff --git a/contracts/gauge/src/bin/schema.rs b/contracts/gauge/src/bin/schema.rs new file mode 100644 index 000000000..282b761ee --- /dev/null +++ b/contracts/gauge/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; + +use gauge_orchestrator::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/gauge/src/contract.rs b/contracts/gauge/src/contract.rs new file mode 100644 index 000000000..25191f657 --- /dev/null +++ b/contracts/gauge/src/contract.rs @@ -0,0 +1,41 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +// use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +/* +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:gauge"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +*/ + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: InstantiateMsg, +) -> Result { + unimplemented!() +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: ExecuteMsg, +) -> Result { + unimplemented!() +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult { + unimplemented!() +} + +#[cfg(test)] +mod tests {} diff --git a/contracts/gauge/src/error.rs b/contracts/gauge/src/error.rs new file mode 100644 index 000000000..4a69d8ff2 --- /dev/null +++ b/contracts/gauge/src/error.rs @@ -0,0 +1,13 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + // Add any other custom errors you like here. + // Look at https://docs.rs/thiserror/1.0.21/thiserror/ for details. +} diff --git a/contracts/gauge/src/helpers.rs b/contracts/gauge/src/helpers.rs new file mode 100644 index 000000000..a39b15588 --- /dev/null +++ b/contracts/gauge/src/helpers.rs @@ -0,0 +1,27 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{to_binary, Addr, CosmosMsg, StdResult, WasmMsg}; + +use crate::msg::ExecuteMsg; + +/// CwTemplateContract is a wrapper around Addr that provides a lot of helpers +/// for working with this. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct CwTemplateContract(pub Addr); + +impl CwTemplateContract { + pub fn addr(&self) -> Addr { + self.0.clone() + } + + pub fn call>(&self, msg: T) -> StdResult { + let msg = to_binary(&msg.into())?; + Ok(WasmMsg::Execute { + contract_addr: self.addr().into(), + msg, + funds: vec![], + } + .into()) + } +} diff --git a/contracts/gauge/src/lib.rs b/contracts/gauge/src/lib.rs new file mode 100644 index 000000000..233dbf557 --- /dev/null +++ b/contracts/gauge/src/lib.rs @@ -0,0 +1,7 @@ +pub mod contract; +mod error; +pub mod helpers; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/gauge/src/msg.rs b/contracts/gauge/src/msg.rs new file mode 100644 index 000000000..e23fe1c5e --- /dev/null +++ b/contracts/gauge/src/msg.rs @@ -0,0 +1,187 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Decimal, Uint128}; + +use wynd_stake::hook::MemberChangedHookMsg; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address of contract to that contains all voting powers (where we query and listen to hooks) + pub voting_powers: String, + /// Address that can add new gauges or stop them + pub owner: String, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Must be compatible with MemberChangedExecuteMsg from wynd-stake. + /// Use this to update + MemberChangedHook(MemberChangedHookMsg), + /// This creates a new Gauge, returns CreateGaugeReply JSON-encoded in the data field. + /// Can only be called by owner + CreateGauge { + /// Name of the gauge (for UI) + title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + adapter: String, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + epoch_size: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set. + /// If unset, there is no minimum percentage, just the `max_options_selected` limit. + min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + max_options_selected: u32, + }, + /// Stops a given gauge, meaning it will not execute any more messages, + /// Or receive any more updates on MemberChangedHook. + /// Ideally, this will allow for eventual deletion of all data on that gauge + StopGauge { gauge: u64 }, + /// Try to add an option. Error if no such gauge, or option already registered. + /// Otherwise check adapter and error if invalid. + /// Can be called by anyone, not just owner + AddOption { gauge: u64, option: String }, + /// Place your vote on the gauge. Can be updated anytime + PlaceVote { + /// Gauge to vote on + gauge: u64, + /// The option to put my vote on. + /// "None" means remove existing vote and abstain + option: Option, + }, + /// Takes a sample of the current tally and execute the proper messages to make it work + Execute { gauge: u64 }, +} + +#[cw_serde] +pub struct CreateGaugeReply { + /// Id of the gauge that was just created + pub id: u64, +} + +/// Queries the gauge exposes +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(GaugeResponse)] + Gauge { id: u64 }, + #[returns(ListGaugesResponse)] + ListGauges { + start_after: Option, + limit: Option, + }, + #[returns(VoteResponse)] + Vote { gauge: u64, voter: String }, + #[returns(ListVotesResponse)] + ListVotes { + gauge: u64, + start_after: Option, + limit: Option, + }, + #[returns(ListOptionsResponse)] + ListOptions { + gauge: u64, + start_after: Option, + limit: Option, + }, + #[returns(SelectedSetResponse)] + SelectedSet { gauge: u64 }, +} + +/// Information about one gauge +#[cw_serde] +pub struct GaugeResponse { + pub id: u64, + /// Name of the gauge (for UI) + pub title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: String, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch_size: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set. + /// If unset, there is no minimum percentage, just the `max_options_selected` limit. + pub min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, + /// True if the gauge is stopped + pub is_stopped: bool, + /// UNIX time (seconds) when next epoch may be executed. May be future or past + pub next_epoch: u64, +} + +/// Information about one gauge +#[cw_serde] +pub struct ListGaugesResponse { + pub gauges: Vec, +} + +/// Information about a vote that was cast. +#[cw_serde] +pub struct VoteInfo { + /// The address that voted. + pub voter: String, + /// Option voted for. + pub option: String, + /// The voting power behind the vote. + pub power: Uint128, +} + +/// Information about a vote. +#[cw_serde] +pub struct VoteResponse { + /// None if no such vote, Some otherwise. + pub vote: Option, +} + +/// Information about all votes on the gauge +#[cw_serde] +pub struct ListVotesResponse { + pub votes: Vec, +} + +/// List all available options ordered by the option string. +/// Also returns the current voting power assigned to that option. +/// You will need to paginate to collect them all. +#[cw_serde] +pub struct ListOptionsResponse { + pub options: Vec<(String, Uint128)>, +} + +/// List the top options by power that would make it into the selected set. +/// Ordered from highest votes to lowest +#[cw_serde] +pub struct SelectedSetResponse { + pub votes: Vec<(String, Uint128)>, +} + +/// Queries the gauge requires from the adapter contract in order to function +#[cw_serde] +#[derive(QueryResponses)] +pub enum AdapterQueryMsg { + #[returns(AllOptionsResponse)] + AllOptions {}, + #[returns(CheckOptionResponse)] + CheckOption { option: String }, + #[returns(SampleGaugeMsgsResponse)] + SampleGaugeMsgs { + /// option along with weight + /// sum of all weights should be 1.0 (within rounding error) + selected: Vec<(String, Decimal)>, + }, +} + +#[cw_serde] +pub struct AllOptionsResponse { + pub options: Vec, +} + +#[cw_serde] +pub struct CheckOptionResponse { + pub valid: bool, +} + +#[cw_serde] +pub struct SampleGaugeMsgsResponse { + // NOTE: I think we will never need CustomMsg here, any reason we should include?? + pub execute: Vec, +} diff --git a/contracts/gauge/src/state.rs b/contracts/gauge/src/state.rs new file mode 100644 index 000000000..f89b31c5b --- /dev/null +++ b/contracts/gauge/src/state.rs @@ -0,0 +1,161 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Decimal, StdResult, Storage, Uint128}; +use cw_storage_plus::{Item, Map}; + +use crate::msg::VoteInfo; + +/// Type alias for u64 to make the map types a bit more self-explanatorys +pub type GaugeId = u64; + +pub const CONFIG: Item = Item::new("config"); +pub const GAUGES: Map = Map::new("gauges"); + +/// This lets us find and update any vote given both voter and gauge. +/// It also lets us iterate over all votes by a given voter on all gauges quite easily. +/// This is needed when a voter weight changes in order to update the guage. +/// It makes it very expensive to show all votes on a given gauge, but that is +/// only used for off-chain UI logic anyway, and we should start work on indexers for that. +pub const VOTES: Map<(&Addr, GaugeId), Vote> = Map::new("votes"); + +#[cw_serde] +pub struct Config { + /// Address of contract to that contains all voting powers (where we query and listen to hooks) + pub voting_powers: Addr, + /// Address that can add new gauges or stop them + pub owner: Addr, +} + +#[cw_serde] +pub struct Gauge { + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: Addr, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch: u64, + /// (Optional) Minimum percentage of votes needed by a given option to be in the selected set + pub min_percent_selected: Option, + /// (Required) Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, + /// True if the gauge is stopped + pub is_stopped: bool, + /// UNIX time (seconds) when next epoch can be executed. If < env.block.time then Execute can be called + pub next_epoch: u64, +} + +#[cw_serde] +pub struct Vote { + /// Option voted for. + pub option: String, + /// The voting power behind the vote. + pub power: Uint128, +} + +/// This can be used on range queries over the votes +pub fn to_vote_info((voter, vote): (Addr, Vote)) -> VoteInfo { + VoteInfo { + voter: voter.into(), + option: vote.option, + power: vote.power, + } +} + +/// Total amount of votes in all options, used to calculate min percentage. +pub const TOTAL_CAST: Map = Map::new("total_power"); + +/// Count how many points each option has per gauge +pub const TALLY: Map<(GaugeId, &str), u128> = Map::new("tally"); +/// Sorted index of options by points, separated by gauge - data field is a placeholder +pub const OPTION_BY_POINTS: Map<(GaugeId, u128, &str), u8> = Map::new("tally_points"); + +/// Updates the tally for one option. +/// The first time a user votes, they get `{old_vote: 0, new_vote: power}` +/// If they change options, call old option with `{old_vote: power, new_vote: 0}` and new option with `{old_vote: 0, new_vote: power}` +/// If a user changes power (member update hook), call existing option with `{old_vote: old_power, new_vote: new_power}` +pub fn update_tally( + storage: &mut dyn Storage, + gauge: GaugeId, + option: &str, + old_vote: u128, + new_vote: u128, +) -> StdResult<()> { + // get old and new values + let old_count = TALLY.may_load(storage, (gauge, option))?; + let count = old_count.unwrap_or_default() + new_vote - old_vote; + + // update main index + TALLY.save(storage, (gauge, option), &count)?; + + // delete old secondary index (if any) + if let Some(old) = old_count { + OPTION_BY_POINTS.remove(storage, (gauge, old, option)); + } + // add new secondary index + OPTION_BY_POINTS.save(storage, (gauge, count, option), &1u8)?; + + // update total count + let total = TOTAL_CAST.may_load(storage, gauge)?.unwrap_or_default(); + let total = total + new_vote - old_vote; + TOTAL_CAST.save(storage, gauge, &total) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::Order; + + use cosmwasm_std::testing::mock_dependencies; + + const GAUGE: u64 = 2; + + /// Let's keep them all the same length for less surprising iteration + const OPTION1: &str = "one"; + const OPTION2: &str = "two"; + // make sure it is alphabetically last + const OPTION3: &str = "zzz"; + + // demonstate how to call update tally and how to query the tallies, + // both by pk and by secondary index + #[test] + fn update_tally_initial_votes_work() { + let mut mock_deps = mock_dependencies(); + let deps = mock_deps.as_mut(); + + update_tally(deps.storage, GAUGE, OPTION1, 0, 250).unwrap(); + update_tally(deps.storage, GAUGE, OPTION2, 0, 400).unwrap(); + update_tally(deps.storage, GAUGE, OPTION3, 0, 100).unwrap(); + + // data in some other tally shouldn't mix with this gauge + update_tally(deps.storage, 17, OPTION3, 0, 55).unwrap(); + update_tally(deps.storage, 16, OPTION1, 0, 123).unwrap(); + + // get all options with primary index (ordered by string value of option) + let options: Vec<_> = TALLY + .prefix(GAUGE) + .range(deps.storage, None, None, Order::Ascending) + .collect::>>() + .unwrap(); + let expected = vec![ + (OPTION1.to_string(), 250u128), + (OPTION2.to_string(), 400u128), + (OPTION3.to_string(), 100u128), + ]; + assert_eq!(options, expected); + + // get them by secondary index, top to bottom + let options: Vec<_> = OPTION_BY_POINTS + .sub_prefix(GAUGE) + .keys(deps.storage, None, None, Order::Descending) + .collect::>>() + .unwrap(); + let expected = vec![ + (400u128, OPTION2.to_string()), + (250u128, OPTION1.to_string()), + (100u128, OPTION3.to_string()), + ]; + assert_eq!(options, expected); + + // total is properly set + let total = TOTAL_CAST.load(deps.storage, GAUGE).unwrap(); + assert_eq!(total, 750u128); + } +} From 0e5880cd9fa9701dcd03b846f7e446cba34d65f4 Mon Sep 17 00:00:00 2001 From: Cosmorama Date: Tue, 10 Jan 2023 21:26:05 +0100 Subject: [PATCH 02/12] Release 1.4.0 --- contracts/gauge/Cargo.toml | 10 +- contracts/gauge/src/contract.rs | 592 ++++++++++++++++++++++- contracts/gauge/src/error.rs | 37 +- contracts/gauge/src/lib.rs | 2 + contracts/gauge/src/msg.rs | 57 ++- contracts/gauge/src/multitest/adapter.rs | 86 ++++ contracts/gauge/src/multitest/gauge.rs | 218 +++++++++ contracts/gauge/src/multitest/mod.rs | 5 + contracts/gauge/src/multitest/suite.rs | 528 ++++++++++++++++++++ contracts/gauge/src/multitest/tally.rs | 322 ++++++++++++ contracts/gauge/src/multitest/voting.rs | 327 +++++++++++++ contracts/gauge/src/state.rs | 475 ++++++++++++++++-- 12 files changed, 2584 insertions(+), 75 deletions(-) create mode 100644 contracts/gauge/src/multitest/adapter.rs create mode 100644 contracts/gauge/src/multitest/gauge.rs create mode 100644 contracts/gauge/src/multitest/mod.rs create mode 100644 contracts/gauge/src/multitest/suite.rs create mode 100644 contracts/gauge/src/multitest/tally.rs create mode 100644 contracts/gauge/src/multitest/voting.rs diff --git a/contracts/gauge/Cargo.toml b/contracts/gauge/Cargo.toml index 0aa153a32..308f80da7 100644 --- a/contracts/gauge/Cargo.toml +++ b/contracts/gauge/Cargo.toml @@ -14,8 +14,9 @@ library = [] [dependencies] cosmwasm-schema = { workspace = true } cosmwasm-std = { workspace = true } -cosmwasm-storage = { workspace = true } +cw-core-interface = { workspace = true } cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } cw2 = { workspace = true } schemars = { workspace = true } serde = { workspace = true } @@ -23,4 +24,11 @@ thiserror = { workspace = true } wynd-stake = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } +cw-core = { workspace = true } cw-multi-test = { workspace = true } +cw-proposal-single = { workspace = true } +cw4 = { workspace = true } +cw4-group = { workspace = true } +cw4-voting = { workspace = true } +voting = { workspace = true } diff --git a/contracts/gauge/src/contract.rs b/contracts/gauge/src/contract.rs index 25191f657..e8ac9aa91 100644 --- a/contracts/gauge/src/contract.rs +++ b/contracts/gauge/src/contract.rs @@ -1,41 +1,595 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; -use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; -// use cw2::set_contract_version; +use cosmwasm_std::{ + to_binary, Addr, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Order, QueryRequest, + Response, StdResult, Uint128, WasmMsg, WasmQuery, +}; +use cw2::set_contract_version; +use cw_core_interface::{ + voting::{Query as DaoQuery, VotingPowerAtHeightResponse}, + ExecuteMsg as DaoExecuteMsg, +}; +use cw_storage_plus::Bound; +use cw_utils::ensure_from_older_version; +use wynd_stake::hook::MemberDiff; use crate::error::ContractError; -use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::msg::{ + AdapterQueryMsg, AllOptionsResponse, CheckOptionResponse, ExecuteMsg, GaugeConfig, + GaugeResponse, InstantiateMsg, ListGaugesResponse, ListOptionsResponse, ListVotesResponse, + MigrateMsg, QueryMsg, SampleGaugeMsgsResponse, SelectedSetResponse, +}; +use crate::state::{ + fetch_last_id, update_tally, votes, Config, Gauge, GaugeId, CONFIG, GAUGES, OPTION_BY_POINTS, + TALLY, TOTAL_CAST, +}; -/* // version info for migration info const CONTRACT_NAME: &str = "crates.io:gauge"; const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); -*/ #[cfg_attr(not(feature = "library"), entry_point)] pub fn instantiate( - _deps: DepsMut, - _env: Env, - _info: MessageInfo, - _msg: InstantiateMsg, + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, ) -> Result { - unimplemented!() + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let voting_powers = deps.api.addr_validate(&msg.voting_powers)?; + let owner = deps.api.addr_validate(&msg.owner)?; + let config = Config { + voting_powers, + owner, + dao_core: info.sender, + }; + CONFIG.save(deps.storage, &config)?; + + for gauge in msg.gauges.unwrap_or_default() { + execute::attach_gauge(deps.branch(), env.clone(), gauge)?; + } + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("owner", &msg.owner) + .add_attribute("voting_powers", &msg.voting_powers)) } #[cfg_attr(not(feature = "library"), entry_point)] pub fn execute( - _deps: DepsMut, - _env: Env, - _info: MessageInfo, - _msg: ExecuteMsg, + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, ) -> Result { - unimplemented!() + match msg { + ExecuteMsg::MemberChangedHook(hook_msg) => { + execute::member_changed(deps, info.sender, hook_msg.diffs) + } + ExecuteMsg::CreateGauge(options) => execute::create_gauge(deps, env, info.sender, options), + ExecuteMsg::StopGauge { gauge } => execute::stop_gauge(deps, info.sender, gauge), + ExecuteMsg::AddOption { gauge, option } => { + execute::add_option(deps, info.sender, gauge, option, true) + } + ExecuteMsg::PlaceVotes { gauge, votes } => { + execute::place_votes(deps, info.sender, gauge, votes) + } + ExecuteMsg::Execute { gauge } => execute::execute(deps, env, gauge), + } +} + +mod execute { + use super::*; + use crate::state::{update_tallies, Vote}; + use std::collections::HashMap; + + pub fn member_changed( + deps: DepsMut, + sender: Addr, + diffs: Vec, + ) -> Result { + // make sure only voting powers contract can activate this endpoint + if sender != CONFIG.load(deps.storage)?.voting_powers { + return Err(ContractError::Unauthorized {}); + } + + let mut response = Response::new().add_attribute("action", "member_changed_hook"); + + for diff in diffs { + response = response.add_attribute("member", &diff.key); + let voter = deps.api.addr_validate(&diff.key)?; + + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in + votes().query_votes_by_voter(deps.as_ref(), &voter, None, Some(query::MAX_LIMIT))? + { + // find change of vote powers + let old = diff.old.unwrap_or_default(); + let new = diff.new.unwrap_or_default(); + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // store new vote power for this user + vote.power = new; + votes().save(deps.storage, &voter, vote.gauge_id, &vote)?; + } + } + + Ok(response) + } + + pub fn create_gauge( + deps: DepsMut, + env: Env, + sender: Addr, + options: GaugeConfig, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let adapter = attach_gauge(deps, env, options)?; + + Ok(Response::new() + .add_attribute("action", "create_gauge") + .add_attribute("adapter", &adapter)) + } + + pub fn attach_gauge( + mut deps: DepsMut, + env: Env, + GaugeConfig { + title, + adapter, + epoch_size, + min_percent_selected, + max_options_selected, + }: GaugeConfig, + ) -> Result { + let adapter = deps.api.addr_validate(&adapter)?; + let gauge = Gauge { + title, + adapter: adapter.clone(), + epoch: epoch_size, + min_percent_selected, + max_options_selected, + is_stopped: false, + next_epoch: env.block.time.seconds() + epoch_size, + }; + let last_id: GaugeId = fetch_last_id(deps.storage)?; + GAUGES.save(deps.storage, last_id, &gauge)?; + + // fetch adapter options + let adapter_options: AllOptionsResponse = + deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: adapter.to_string(), + msg: to_binary(&AdapterQueryMsg::AllOptions {})?, + }))?; + adapter_options.options.into_iter().try_for_each(|option| { + execute::add_option(deps.branch(), adapter.clone(), last_id, option, false)?; + Ok::<_, ContractError>(()) + })?; + + Ok(adapter) + } + + pub fn stop_gauge( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let gauge = GAUGES.load(deps.storage, gauge_id)?; + let gauge = Gauge { + is_stopped: true, + ..gauge + }; + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new() + .add_attribute("action", "stop_gauge") + .add_attribute("gauge_id", gauge_id.to_string())) + } + + pub fn add_option( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + option: String, + // must be true if option is added by execute message + check_option: bool, + ) -> Result { + // check is such option already exists + if TALLY.has(deps.as_ref().storage, (gauge_id, &option)) { + return Err(ContractError::OptionAlreadyExists { option, gauge_id }); + }; + + // only options added from gauge creation level should not be validated and can + // have 0 points as assigned voting power. + if check_option { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + // query gauge adapter if it is valid + let adapter_option: CheckOptionResponse = deps + .querier + .query_wasm_smart( + gauge.adapter, + &AdapterQueryMsg::CheckOption { + option: option.clone(), + }, + ) + .map_err(|_| ContractError::OptionInvalidByAdapter { + option: option.clone(), + gauge_id, + })?; + if !adapter_option.valid { + return Err(ContractError::OptionInvalidByAdapter { option, gauge_id }); + } + // If it is a user adding option, query him for voting power in order to prevent + // spam from nonvoting users + let voting_power = deps + .querier + .query::(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: CONFIG.load(deps.storage)?.voting_powers.to_string(), + msg: to_binary(&DaoQuery::VotingPowerAtHeight { + address: sender.to_string(), + height: None, + })?, + }))? + .power; + if voting_power.is_zero() { + return Err(ContractError::NoVotingPower(sender.to_string())); + } + } + + update_tally(deps.storage, gauge_id, &option, 0u128, 0u128)?; + + Ok(Response::new() + .add_attribute("action", "add_option") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()) + .add_attribute("option", option)) + } + + pub fn place_votes( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + new_votes: Option>, + ) -> Result { + if !GAUGES.has(deps.storage, gauge_id) { + return Err(ContractError::GaugeMissing(gauge_id)); + } + + // make sure sums work out + let new_votes = new_votes.unwrap_or_default(); + let total_weight = new_votes.iter().map(|v| v.weight).sum(); + if total_weight > Decimal::one() { + return Err(ContractError::TooMuchVotingWeight(total_weight)); + } + + // load voter power from voting powers contract (DAO) + let voting_power = deps + .querier + .query::(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: CONFIG.load(deps.storage)?.voting_powers.to_string(), + msg: to_binary(&DaoQuery::VotingPowerAtHeight { + address: sender.to_string(), + height: None, + })?, + }))? + .power; + if voting_power.is_zero() { + return Err(ContractError::NoVotingPower(sender.to_string())); + } + + let previous_vote = votes().may_load(deps.storage, &sender, gauge_id)?; + if previous_vote.is_none() && new_votes.is_empty() { + return Err(ContractError::CannotRemoveNonexistingVote {}); + } + + // first, calculate a diff between new_vote and previous_vote (option -> (old, new)) + let previous_vote = previous_vote.unwrap_or_default(); + let power = previous_vote.power; + let mut diff: HashMap<&str, (u128, u128)> = previous_vote + .votes + .iter() + .map(|v| (v.option.as_str(), ((power * v.weight).u128(), 0u128))) + .collect(); + for v in new_votes.iter() { + let new = (voting_power * v.weight).u128(); + let add = match diff.remove(v.option.as_str()) { + Some((old, _)) => (old, new), + None => (0, new), + }; + diff.insert(&v.option, add); + } + + // second, test any new options are valid, + // only for those voted for first time (others have already been checked) + for new_opt in diff + .iter() + .filter(|(_, (old, _))| *old == 0) + .map(|(&k, _)| k) + { + if !TALLY.has(deps.storage, (gauge_id, new_opt)) { + return Err(ContractError::OptionDoesNotExists { + option: new_opt.to_string(), + gauge_id, + }); + } + } + + // third, update tally based on diff + let updates: Vec<(&str, u128, u128)> = diff + .iter() + .map(|(&k, (old, new))| (k, *old, *new)) + .collect(); + update_tallies(deps.storage, gauge_id, updates)?; + + // finally, update the votes for this user + if new_votes.is_empty() { + // completely remove sender's votes + votes().remove_votes(deps.storage, &sender, gauge_id)?; + } else { + // store sender's new votes (overwriting old votes) + votes().set_votes(deps.storage, &sender, gauge_id, new_votes, voting_power)?; + } + + let response = Response::new() + .add_attribute("action", "place_vote") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()); + Ok(response) + } + + pub fn execute(deps: DepsMut, env: Env, gauge_id: u64) -> Result { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + + if gauge.is_stopped { + return Err(ContractError::GaugeStopped(gauge_id)); + } + + let current_epoch = env.block.time.seconds(); + if current_epoch < gauge.next_epoch { + return Err(ContractError::EpochNotReached { + gauge_id, + current_epoch, + next_epoch: gauge.next_epoch, + }); + } + + // this set contains tuple (option, total_voted_power) + // for adapter query, this needs to be transformed into (option, voted_weight) + let selected_set_with_powers = query::selected_set(deps.as_ref(), gauge_id)?.votes; + + // This is a bit hacky solution to accomplish 3 things: + // - remove executed option from TALLY + // - remove executed option from OPTION_BY_POINTS + // - summarizing all power to be subtracted from TOTAL_CAST + // Placed here to avoid copy of either whole iterator or its elements in second loop + // down below. + let selected_powers_sum = selected_set_with_powers + .iter() + .map(|(option, power)| { + let power = power.u128(); + TALLY.remove(deps.storage, (gauge_id, option)); + OPTION_BY_POINTS.remove(deps.storage, (gauge_id, power, option)); + power + }) + .sum::(); + + // calculate "local" ratios of voted options per total power of all selected options + let selected = selected_set_with_powers + .into_iter() + .map(|(option, power)| Ok((option, Decimal::from_ratio(power, selected_powers_sum)))) + .collect::>>()?; + + // query gauge adapter for execute messages for DAO + let execute_messages: SampleGaugeMsgsResponse = deps.querier.query_wasm_smart( + gauge.adapter, + &AdapterQueryMsg::SampleGaugeMsgs { selected }, + )?; + + let config = CONFIG.load(deps.storage)?; + let execute_msg = WasmMsg::Execute { + contract_addr: config.dao_core.to_string(), + msg: to_binary(&DaoExecuteMsg::ExecuteProposalHook { + msgs: execute_messages.execute, + })?, + funds: vec![], + }; + + // update total cast to reflect executed messages + TOTAL_CAST.update(deps.storage, gauge_id, |total_cast| -> StdResult<_> { + Ok(total_cast.unwrap_or_default() - selected_powers_sum) + })?; + + Ok(Response::new() + .add_attribute("action", "execute_tally") + .add_message(execute_msg)) + } } #[cfg_attr(not(feature = "library"), entry_point)] -pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult { - unimplemented!() +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Info {} => Ok(to_binary(&query::info(deps)?)?), + QueryMsg::Gauge { id } => Ok(to_binary(&query::gauge(deps, id)?)?), + QueryMsg::ListGauges { start_after, limit } => { + Ok(to_binary(&query::list_gauges(deps, start_after, limit)?)?) + } + QueryMsg::Vote { gauge, voter } => Ok(to_binary(&query::vote(deps, gauge, voter)?)?), + QueryMsg::ListVotes { + gauge, + start_after, + limit, + } => Ok(to_binary(&query::list_votes( + deps, + gauge, + start_after, + limit, + )?)?), + QueryMsg::ListOptions { + gauge, + start_after, + limit, + } => Ok(to_binary(&query::list_options( + deps, + gauge, + start_after, + limit, + )?)?), + QueryMsg::SelectedSet { gauge } => Ok(to_binary(&query::selected_set(deps, gauge)?)?), + } } -#[cfg(test)] -mod tests {} +mod query { + use super::*; + + use crate::msg::{VoteInfo, VoteResponse}; + use cw_core_interface::voting::InfoResponse; + + pub fn info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + Ok(InfoResponse { info }) + } + + fn to_gauge_response(gauge_id: GaugeId, gauge: Gauge) -> GaugeResponse { + GaugeResponse { + id: gauge_id, + title: gauge.title, + adapter: gauge.adapter.to_string(), + epoch_size: gauge.epoch, + min_percent_selected: gauge.min_percent_selected, + max_options_selected: gauge.max_options_selected, + is_stopped: gauge.is_stopped, + next_epoch: gauge.next_epoch, + } + } + + pub fn gauge(deps: Deps, gauge_id: GaugeId) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + Ok(to_gauge_response(gauge_id, gauge)) + } + + // settings for pagination + pub const MAX_LIMIT: u32 = 100; + pub const DEFAULT_LIMIT: u32 = 30; + + pub fn list_gauges( + deps: Deps, + start_after: Option, + limit: Option, + ) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + + Ok(ListGaugesResponse { + gauges: GAUGES + .range(deps.storage, start, None, Order::Ascending) + .map(|item| { + let (id, gauge) = item?; + Ok(to_gauge_response(id, gauge)) + }) + .take(limit) + .collect::>>()?, + }) + } + + pub fn vote(deps: Deps, gauge_id: u64, voter: String) -> StdResult { + let voter_addr = deps.api.addr_validate(&voter)?; + let vote = votes() + .may_load(deps.storage, &voter_addr, gauge_id)? + .map(|v| VoteInfo { + voter, + votes: v.votes, + }); + Ok(VoteResponse { vote }) + } + + pub fn list_votes( + deps: Deps, + gauge_id: u64, + start_after: Option, + limit: Option, + ) -> StdResult { + Ok(ListVotesResponse { + votes: votes().query_votes_by_gauge(deps, gauge_id, start_after, limit)?, + }) + } + + pub fn list_options( + deps: Deps, + gauge_id: u64, + start_after: Option, + limit: Option, + ) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start_after = start_after.as_ref().map(|s| Bound::exclusive(s.as_str())); + + Ok(ListOptionsResponse { + options: TALLY + .prefix(gauge_id) + .range(deps.storage, start_after, None, Order::Ascending) + .map(|option| { + let (option, power) = option?; + Ok((option, Uint128::new(power))) + }) + .take(limit) + .collect::>>()?, + }) + } + + pub fn selected_set(deps: Deps, gauge_id: u64) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + let total_cast = TOTAL_CAST.load(deps.storage, gauge_id)?; + + // This is sorted index, but requires manual filtering - cannot be prefixed + // given our requirements + let votes = OPTION_BY_POINTS + .sub_prefix(gauge_id) + .range(deps.storage, None, None, Order::Descending) + .filter(|o| { + let ((power, _), _) = o.as_ref().unwrap(); + if let Some(min_percent_selected) = gauge.min_percent_selected { + Decimal::from_ratio(*power, total_cast) >= min_percent_selected + } else { + // filter out options without a vote + *power != 0u128 + } + }) + .map(|o| { + let ((power, option), _) = o?; + Ok((option, Uint128::new(power))) + }) + .take(gauge.max_options_selected as usize) + .collect::>>()?; + + Ok(SelectedSetResponse { votes }) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::new()) +} diff --git a/contracts/gauge/src/error.rs b/contracts/gauge/src/error.rs index 4a69d8ff2..241867d9c 100644 --- a/contracts/gauge/src/error.rs +++ b/contracts/gauge/src/error.rs @@ -1,13 +1,42 @@ -use cosmwasm_std::StdError; +use cosmwasm_std::{Decimal, StdError}; use thiserror::Error; -#[derive(Error, Debug)] +#[derive(Error, Debug, PartialEq)] pub enum ContractError { #[error("{0}")] Std(#[from] StdError), #[error("Unauthorized")] Unauthorized {}, - // Add any other custom errors you like here. - // Look at https://docs.rs/thiserror/1.0.21/thiserror/ for details. + + #[error("Gauge with ID {0} does not exists")] + GaugeMissing(u64), + + #[error("Voted for {0} times total voting power. Limit 1.0")] + TooMuchVotingWeight(Decimal), + + #[error("User {0} has no voting power")] + NoVotingPower(String), + + #[error("Option {option} already exists for gauge ID {gauge_id}")] + OptionAlreadyExists { option: String, gauge_id: u64 }, + + #[error("Option {option} has been judged as invalid by gauge adapter of gauge ID {gauge_id}")] + OptionInvalidByAdapter { option: String, gauge_id: u64 }, + + #[error("Option {option} does not exists for gauge ID {gauge_id}")] + OptionDoesNotExists { option: String, gauge_id: u64 }, + + #[error("Gauge ID {gauge_id} cannot execute because next_epoch is not yet reached: current {current_epoch}, next_epoch: {next_epoch}")] + EpochNotReached { + gauge_id: u64, + current_epoch: u64, + next_epoch: u64, + }, + + #[error("Gauge ID {0} cannot execute because it is stopped")] + GaugeStopped(u64), + + #[error("Trying to remove vote that does not exists")] + CannotRemoveNonexistingVote {}, } diff --git a/contracts/gauge/src/lib.rs b/contracts/gauge/src/lib.rs index 233dbf557..8e1682396 100644 --- a/contracts/gauge/src/lib.rs +++ b/contracts/gauge/src/lib.rs @@ -2,6 +2,8 @@ pub mod contract; mod error; pub mod helpers; pub mod msg; +#[cfg(test)] +mod multitest; pub mod state; pub use crate::error::ContractError; diff --git a/contracts/gauge/src/msg.rs b/contracts/gauge/src/msg.rs index e23fe1c5e..4f925a04b 100644 --- a/contracts/gauge/src/msg.rs +++ b/contracts/gauge/src/msg.rs @@ -1,6 +1,7 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::{CosmosMsg, Decimal, Uint128}; +use crate::state::Vote; use wynd_stake::hook::MemberChangedHookMsg; #[cw_serde] @@ -9,6 +10,26 @@ pub struct InstantiateMsg { pub voting_powers: String, /// Address that can add new gauges or stop them pub owner: String, + /// Allow attaching multiple adaptors during instantiation. + /// Important, as instantiation and CreateGauge both come from DAO proposals + /// and without this argument, you need 2 cycles to create and configure a gauge + pub gauges: Option>, +} + +#[cw_serde] +pub struct GaugeConfig { + /// Name of the gauge (for UI) + pub title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: String, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch_size: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set. + /// If unset, there is no minimum percentage, just the `max_options_selected` limit. + pub min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, } #[cw_serde] @@ -18,35 +39,24 @@ pub enum ExecuteMsg { MemberChangedHook(MemberChangedHookMsg), /// This creates a new Gauge, returns CreateGaugeReply JSON-encoded in the data field. /// Can only be called by owner - CreateGauge { - /// Name of the gauge (for UI) - title: String, - /// Address of contract to serve gauge-specific info (AdapterQueryMsg) - adapter: String, - /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 - epoch_size: u64, - /// Minimum percentage of votes needed by a given option to be in the selected set. - /// If unset, there is no minimum percentage, just the `max_options_selected` limit. - min_percent_selected: Option, - /// Maximum number of Options to make the selected set. Needed even with - /// `min_percent_selected` to provide some guarantees on gas usage of this query. - max_options_selected: u32, - }, + CreateGauge(GaugeConfig), /// Stops a given gauge, meaning it will not execute any more messages, /// Or receive any more updates on MemberChangedHook. /// Ideally, this will allow for eventual deletion of all data on that gauge StopGauge { gauge: u64 }, + // WISH: make this implicit - call it inside PlaceVote. + // If not, I would just make it invisible to user in UI (smart client adds it if needed) /// Try to add an option. Error if no such gauge, or option already registered. /// Otherwise check adapter and error if invalid. /// Can be called by anyone, not just owner AddOption { gauge: u64, option: String }, /// Place your vote on the gauge. Can be updated anytime - PlaceVote { + PlaceVotes { /// Gauge to vote on gauge: u64, - /// The option to put my vote on. - /// "None" means remove existing vote and abstain - option: Option, + /// The options to put my vote on, along with proper weights (must sum up to 1.0) + /// "None" means remove existing votes and abstain + votes: Option>, }, /// Takes a sample of the current tally and execute the proper messages to make it work Execute { gauge: u64 }, @@ -62,6 +72,8 @@ pub struct CreateGaugeReply { #[cw_serde] #[derive(QueryResponses)] pub enum QueryMsg { + #[returns(cw_core_interface::voting::InfoResponse)] + Info {}, #[returns(GaugeResponse)] Gauge { id: u64 }, #[returns(ListGaugesResponse)] @@ -120,10 +132,8 @@ pub struct ListGaugesResponse { pub struct VoteInfo { /// The address that voted. pub voter: String, - /// Option voted for. - pub option: String, - /// The voting power behind the vote. - pub power: Uint128, + /// List of all votes with power + pub votes: Vec, } /// Information about a vote. @@ -185,3 +195,6 @@ pub struct SampleGaugeMsgsResponse { // NOTE: I think we will never need CustomMsg here, any reason we should include?? pub execute: Vec, } + +#[cw_serde] +pub enum MigrateMsg {} diff --git a/contracts/gauge/src/multitest/adapter.rs b/contracts/gauge/src/multitest/adapter.rs new file mode 100644 index 000000000..c15069880 --- /dev/null +++ b/contracts/gauge/src/multitest/adapter.rs @@ -0,0 +1,86 @@ +//! Gauge adapter contract to mock in tests. +//! I wrote it so that InstantiateMsg contains list of initially +//! available options. Query for CheckOption checks if option is already added, +//! otherwise returns true - option is valid. + +use cosmwasm_std::{ + coin, to_binary, Binary, Coin, CosmosMsg, Decimal, Deps, DepsMut, Empty, Env, MessageInfo, + Order, Response, StdError, StdResult, +}; +use cw_multi_test::{Contract, ContractWrapper}; +use cw_storage_plus::{Item, Map}; +use serde::{Deserialize, Serialize}; + +use crate::msg::{ + AdapterQueryMsg, AllOptionsResponse, CheckOptionResponse, SampleGaugeMsgsResponse, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstantiateMsg { + pub options: Vec, + pub to_distribute: Coin, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EmptyMsg {} + +const OPTIONS: Map = Map::new("options"); +const TO_DISTRIBUTE: Item = Item::new("to_spend"); + +fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + msg.options + .into_iter() + .try_for_each(|option| OPTIONS.save(deps.storage, option, &true))?; + TO_DISTRIBUTE.save(deps.storage, &msg.to_distribute)?; + Ok(Response::default()) +} + +fn execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: EmptyMsg, +) -> Result { + Ok(Response::new()) +} + +fn query(deps: Deps, _env: Env, msg: AdapterQueryMsg) -> Result { + match msg { + AdapterQueryMsg::AllOptions {} => to_binary(&AllOptionsResponse { + options: OPTIONS + .keys(deps.storage, None, None, Order::Ascending) + .collect::>>()?, + }), + AdapterQueryMsg::CheckOption { option: _ } => { + to_binary(&CheckOptionResponse { valid: true }) + } + AdapterQueryMsg::SampleGaugeMsgs { selected } => { + let to_distribute = TO_DISTRIBUTE.load(deps.storage)?; + let mut weights_sum = Decimal::zero(); + let execute = selected + .into_iter() + .map(|(option, weight)| { + weights_sum += weight; + CosmosMsg::Bank(cosmwasm_std::BankMsg::Send { + to_address: option, + amount: vec![coin( + (to_distribute.amount * weight).u128(), + to_distribute.denom.clone(), + )], + }) + }) + .collect::>(); + to_binary(&SampleGaugeMsgsResponse { execute }) + } + } +} + +pub fn contract() -> Box> { + let contract = ContractWrapper::new_with_empty(execute, instantiate, query); + Box::new(contract) +} diff --git a/contracts/gauge/src/multitest/gauge.rs b/contracts/gauge/src/multitest/gauge.rs new file mode 100644 index 000000000..82a542100 --- /dev/null +++ b/contracts/gauge/src/multitest/gauge.rs @@ -0,0 +1,218 @@ +use cosmwasm_std::{Decimal, Uint128}; +use voting::Vote; + +use super::suite::SuiteBuilder; + +use crate::error::ContractError; +use crate::msg::GaugeResponse; + +const EPOCH: u64 = 7 * 86_400; + +#[test] +fn create_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + // Second proposal module is cw proposal single, first one is newly added gauge + assert_eq!(proposal_modules.len(), 2); + let gauge_contract = proposal_modules[0].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + ) + .unwrap(); + + let response = suite.query_gauge(gauge_contract, 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + } + ); +} + +/// attach adaptor in instantiate +#[test] +fn execute_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 1000u128 + ); +} + +#[test] +fn execute_stopped_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + let gauge_id = 0; + + // stop the gauge by not-owner + let err = suite + .stop_gauge(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + // stop the gauge by owner + suite + .stop_gauge(&gauge_contract, suite.owner.clone(), gauge_id) + .unwrap(); + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + // Despite gauge being stopped, user + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!( + ContractError::GaugeStopped(gauge_id), + err.downcast().unwrap() + ); +} diff --git a/contracts/gauge/src/multitest/mod.rs b/contracts/gauge/src/multitest/mod.rs new file mode 100644 index 000000000..b66a57246 --- /dev/null +++ b/contracts/gauge/src/multitest/mod.rs @@ -0,0 +1,5 @@ +mod adapter; +mod gauge; +mod suite; +mod tally; +mod voting; diff --git a/contracts/gauge/src/multitest/suite.rs b/contracts/gauge/src/multitest/suite.rs new file mode 100644 index 000000000..c9742baa6 --- /dev/null +++ b/contracts/gauge/src/multitest/suite.rs @@ -0,0 +1,528 @@ +use anyhow::Result as AnyResult; + +use cosmwasm_std::{coin, to_binary, Addr, Coin, CosmosMsg, Decimal, StdResult, Uint128, WasmMsg}; +use cw4::Member; +use cw4_voting::msg::InstantiateMsg as VotingInstantiateMsg; +use cw_core::msg::{ + Admin, ExecuteMsg as CoreExecuteMsg, InstantiateMsg as CoreInstantiateMsg, + ModuleInstantiateInfo, QueryMsg as CoreQueryMsg, +}; +use cw_multi_test::{App, AppResponse, ContractWrapper, Executor}; +use cw_proposal_single::{ + msg::ExecuteMsg as ProposalSingleExecuteMsg, + msg::InstantiateMsg as ProposalSingleInstantiateMsg, msg::QueryMsg as ProposalSingleQueryMsg, + query::ProposalListResponse, state::Executor as ProposalSingleExecutor, +}; +use cw_utils::Duration; +use voting::{PercentageThreshold, Threshold, Vote}; + +use super::adapter::{contract as adapter_contract, InstantiateMsg as AdapterInstantiateMsg}; +use crate::msg::{ + ExecuteMsg, GaugeConfig, GaugeResponse, InstantiateMsg, ListOptionsResponse, ListVotesResponse, + QueryMsg, SelectedSetResponse, VoteInfo, VoteResponse, +}; + +pub const BLOCK_TIME: u64 = 5; + +fn store_gauge(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + )); + + app.store_code(contract) +} + +fn store_group(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + cw4_group::contract::execute, + cw4_group::contract::instantiate, + cw4_group::contract::query, + )); + + app.store_code(contract) +} + +fn store_voting(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + cw4_voting::contract::execute, + cw4_voting::contract::instantiate, + cw4_voting::contract::query, + ) + .with_reply_empty(cw4_voting::contract::reply), + ); + + app.store_code(contract) +} + +fn store_proposal_single(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + cw_proposal_single::contract::execute, + cw_proposal_single::contract::instantiate, + cw_proposal_single::contract::query, + )); + + app.store_code(contract) +} + +fn store_core(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + cw_core::contract::execute, + cw_core::contract::instantiate, + cw_core::contract::query, + ) + .with_reply_empty(cw_core::contract::reply), + ); + + app.store_code(contract) +} + +#[derive(Debug)] +pub struct SuiteBuilder { + voting_members: Vec, + initial_core_balance: Option, +} + +impl SuiteBuilder { + pub fn new() -> Self { + Self { + voting_members: vec![], + initial_core_balance: None, + } + } + + pub fn with_core_balance(mut self, balance: (u128, &str)) -> Self { + self.initial_core_balance = Some(coin(balance.0, balance.1)); + self + } + + pub fn with_voting_members(mut self, members: &[(&str, u64)]) -> Self { + self.voting_members = members + .iter() + .map(|(addr, weight)| Member { + addr: addr.to_string(), + weight: *weight, + }) + .collect::>(); + self + } + + #[track_caller] + pub fn build(self) -> Suite { + let mut app = App::default(); + let owner = Addr::unchecked("owner"); + + // instantiate cw4-voting as voting contract + let group_code_id = store_group(&mut app); + let voting_code_id = store_voting(&mut app); + let voting_module = ModuleInstantiateInfo { + code_id: voting_code_id, + msg: to_binary(&VotingInstantiateMsg { + cw4_group_code_id: group_code_id, + initial_members: self.voting_members, + }) + .unwrap(), + admin: Admin::Address { + addr: owner.to_string(), + }, + label: "CW4 Voting Contract".to_owned(), + }; + + // instantiate proposal_single module + let proposal_single_code_id = store_proposal_single(&mut app); + let proposal_module = ModuleInstantiateInfo { + code_id: proposal_single_code_id, + msg: to_binary(&ProposalSingleInstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(66666666), + min_voting_period: None, + allow_revoting: false, + deposit_info: None, + executor: ProposalSingleExecutor::Members, + }) + .unwrap(), + admin: Admin::Address { + addr: owner.to_string(), + }, + label: "Proposal Single Contract".to_owned(), + }; + + // intantiate core contract, + let core_code_id = store_core(&mut app); + let core = app + .instantiate_contract( + core_code_id, + owner.clone(), + &CoreInstantiateMsg { + admin: Some(owner.to_string()), + name: "CW Core contract".to_owned(), + description: "Hub between voting end executing".to_owned(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: voting_module, + proposal_modules_instantiate_info: vec![proposal_module], + initial_items: None, + }, + &[], + "CW CORE", + None, + ) + .unwrap(); + + if let Some(core_balance) = self.initial_core_balance { + app.init_modules(|router, _, storage| -> AnyResult<()> { + router.bank.init_balance(storage, &core, vec![core_balance]) + }) + .unwrap(); + } + + let voting_contract: Addr = app + .wrap() + .query_wasm_smart(&core, &CoreQueryMsg::VotingModule {}) + .unwrap(); + let proposal_single_contract: Vec = app + .wrap() + .query_wasm_smart( + &core, + &CoreQueryMsg::ProposalModules { + start_at: None, + limit: None, + }, + ) + .unwrap(); + + let gauge_code_id = store_gauge(&mut app); + let gauge_adapter_code_id = app.store_code(adapter_contract()); + + Suite { + owner: owner.to_string(), + app, + core, + voting: voting_contract, + proposal_single: proposal_single_contract[0].clone(), + gauge_code_id, + gauge_adapter_code_id, + } + } +} + +pub struct Suite { + pub owner: String, + app: App, + core: Addr, + voting: Addr, + proposal_single: Addr, + gauge_code_id: u64, + gauge_adapter_code_id: u64, +} + +impl Suite { + pub fn advance_blocks(&mut self, blocks: u64) { + self.app.update_block(|block| { + block.time = block.time.plus_seconds(BLOCK_TIME * blocks); + block.height += blocks; + }); + } + + pub fn advance_time(&mut self, seconds: u64) { + self.app.update_block(|block| { + block.time = block.time.plus_seconds(seconds); + block.height += seconds / BLOCK_TIME; + }); + } + + pub fn next_block(&mut self) { + self.advance_blocks(1) + } + + pub fn current_time(&self) -> u64 { + self.app.block_info().time.seconds() + } + + pub fn stop_gauge( + &mut self, + gauge: &Addr, + sender: impl Into, + gauge_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge.clone(), + &ExecuteMsg::StopGauge { gauge: gauge_id }, + &[], + ) + } + + pub fn add_option( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::AddOption { + gauge: gauge_id, + option: option.into(), + }, + &[], + ) + } + + /// Helper to vote for a single option + pub fn place_vote( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into>, + ) -> AnyResult { + self.place_votes( + gauge, + voter, + gauge_id, + option.into().map(|o| vec![(o, Decimal::one())]), + ) + } + + pub fn place_votes( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + votes: impl Into>>, + ) -> AnyResult { + let votes = votes.into().map(|v| { + v.into_iter() + .map(|(option, weight)| crate::state::Vote { option, weight }) + .collect::>() + }); + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::PlaceVotes { + gauge: gauge_id, + votes, + }, + &[], + ) + } + + pub fn execute_options( + &mut self, + gauge: &Addr, + sender: impl Into, + gauge_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge.clone(), + &ExecuteMsg::Execute { gauge: gauge_id }, + &[], + ) + } + + pub fn query_gauge(&self, gauge_contract: Addr, id: u64) -> StdResult { + self.app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::Gauge { id }) + } + + pub fn query_selected_set( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult> { + let set: SelectedSetResponse = self + .app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::SelectedSet { gauge: id })?; + Ok(set.votes) + } + + pub fn query_list_options( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult> { + let set: ListOptionsResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::ListOptions { + gauge: id, + start_after: None, + limit: None, + }, + )?; + Ok(set.options) + } + + pub fn query_vote( + &self, + gauge_contract: &Addr, + id: u64, + voter: impl Into, + ) -> StdResult> { + let vote: VoteResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::Vote { + gauge: id, + voter: voter.into(), + }, + )?; + Ok(vote.vote) + } + + pub fn query_list_votes(&self, gauge_contract: &Addr, id: u64) -> StdResult> { + let vote: ListVotesResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::ListVotes { + gauge: id, + start_after: None, + limit: None, + }, + )?; + Ok(vote.votes) + } + + // ----------------------------------------------------- + + pub fn propose_update_proposal_module( + &mut self, + proposer: impl Into, + gauge_config: impl Into>>, + ) -> AnyResult { + let propose_msg = ProposalSingleExecuteMsg::Propose { + title: "gauge as proposal module".to_owned(), + description: "Propose core to set gauge as proposal module".to_owned(), + msgs: vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: self.core.to_string(), + msg: to_binary(&CoreExecuteMsg::UpdateProposalModules { + to_add: vec![ModuleInstantiateInfo { + code_id: self.gauge_code_id, + msg: to_binary(&InstantiateMsg { + voting_powers: self.voting.to_string(), + owner: self.owner.clone(), + gauges: gauge_config.into(), + })?, + admin: Admin::Address { + addr: self.owner.clone(), + }, + label: "CW4 Voting Contract".to_owned(), + }], + to_remove: vec![], + })?, + funds: vec![], + })], + }; + self.app.execute_contract( + Addr::unchecked(proposer), + self.proposal_single.clone(), + &propose_msg, + &[], + ) + } + + pub fn instantiate_adapter_and_create_gauge( + &mut self, + gauge_contract: Addr, + options: &[&str], + to_distribute: (u128, &str), + ) -> AnyResult { + let option = self.instantiate_adapter_and_return_config(options, to_distribute)?; + let gauge_adapter = option.adapter.clone(); + self.app.execute_contract( + Addr::unchecked(&self.owner), + gauge_contract, + &ExecuteMsg::CreateGauge(option), + &[], + )?; + Ok(Addr::unchecked(gauge_adapter)) + } + + pub fn instantiate_adapter_and_return_config( + &mut self, + options: &[&str], + to_distribute: (u128, &str), + ) -> AnyResult { + let gauge_adapter = self.app.instantiate_contract( + self.gauge_adapter_code_id, + Addr::unchecked(&self.owner), + &AdapterInstantiateMsg { + options: options.iter().map(|&s| s.into()).collect(), + to_distribute: coin(to_distribute.0, to_distribute.1), + }, + &[], + "gauge adapter", + None, + )?; + + Ok(GaugeConfig { + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: 7 * 86400, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + }) + } + + pub fn place_vote_single( + &mut self, + voter: impl Into, + proposal_id: u64, + vote: Vote, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + self.proposal_single.clone(), + &ProposalSingleExecuteMsg::Vote { proposal_id, vote }, + &[], + ) + } + + pub fn execute_single_proposal( + &mut self, + executor: impl Into, + proposal_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(executor), + self.proposal_single.clone(), + &ProposalSingleExecuteMsg::Execute { proposal_id }, + &[], + ) + } + + pub fn list_proposals(&self) -> StdResult> { + let list: ProposalListResponse = self.app.wrap().query_wasm_smart( + self.proposal_single.clone(), + &ProposalSingleQueryMsg::ListProposals { + start_after: None, + limit: None, + }, + )?; + Ok(list.proposals.into_iter().map(|prop| prop.id).collect()) + } + + pub fn query_proposal_modules(&self) -> StdResult> { + self.app.wrap().query_wasm_smart( + self.core.clone(), + &CoreQueryMsg::ProposalModules { + start_at: None, + limit: None, + }, + ) + } + + pub fn query_balance(&self, account: &str, denom: &str) -> StdResult { + let balance = self.app.wrap().query_balance(account, denom)?; + Ok(balance.amount.u128()) + } +} diff --git a/contracts/gauge/src/multitest/tally.rs b/contracts/gauge/src/multitest/tally.rs new file mode 100644 index 000000000..404a2fd47 --- /dev/null +++ b/contracts/gauge/src/multitest/tally.rs @@ -0,0 +1,322 @@ +use cosmwasm_std::Uint128; +use voting::Vote; + +use super::suite::SuiteBuilder; + +#[test] +fn multiple_options_one_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let voter3 = "voter3"; + let voter4 = "voter4"; + let voter5 = "voter5"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority... + (voter2, 120), + (voter3, 130), + (voter4, 140), + (voter5, 150), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2", "option3", "option4", "option5"], + reward_to_distribute, + ) + .unwrap(); + let gauge_id = 0; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("option1".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter3.to_owned(), + gauge_id, + Some("option3".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter4.to_owned(), + gauge_id, + Some("option4".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter5.to_owned(), + gauge_id, + Some("option5".into()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(600)), + ("option5".to_owned(), Uint128::new(150)), + ("option4".to_owned(), Uint128::new(140)), + ("option3".to_owned(), Uint128::new(130)), + ("option2".to_owned(), Uint128::new(120)) + ] + ); + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("option2".into()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + selected_set, + vec![ + ("option2".to_owned(), Uint128::new(720)), + ("option5".to_owned(), Uint128::new(150)), + ("option4".to_owned(), Uint128::new(140)), + ("option3".to_owned(), Uint128::new(130)), + ] + ); +} + +/// create one in instantiate, other later via create +#[test] +fn multiple_options_two_gauges() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let voter3 = "voter3"; + let voter4 = "voter4"; + let voter5 = "voter5"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority + (voter2, 120), + (voter3, 130), + (voter4, 140), + (voter5, 150), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&["option1", "option2"], reward_to_distribute) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + let first_gauge_id = 0; + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option3", "option4", "option5"], + reward_to_distribute, + ) + .unwrap(); + let second_gauge_id = 1; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter3.to_owned(), + second_gauge_id, + Some("option3".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter4.to_owned(), + second_gauge_id, + Some("option5".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter5.to_owned(), + second_gauge_id, + Some("option5".into()), + ) + .unwrap(); + + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![("option2".to_owned(), Uint128::new(720))] + ); + + let selected_set = suite + .query_selected_set(&gauge_contract, second_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![ + ("option5".to_owned(), Uint128::new(290)), + ("option3".to_owned(), Uint128::new(130)), + ] + ); +} + +#[test] +fn not_voted_options_are_not_selected() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority + (voter2, 120), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2", "option3", "option4"], + reward_to_distribute, + ) + .unwrap(); + let first_gauge_id = 0; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option1".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(600)), + ("option2".to_owned(), Uint128::new(120)), + ] + ); + + // first voter changes vote to option2 + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![("option2".to_owned(), Uint128::new(720)),] + ); +} diff --git a/contracts/gauge/src/multitest/voting.rs b/contracts/gauge/src/multitest/voting.rs new file mode 100644 index 000000000..1ea48d1b1 --- /dev/null +++ b/contracts/gauge/src/multitest/voting.rs @@ -0,0 +1,327 @@ +use cosmwasm_std::{Decimal, Uint128}; +use voting::Vote; + +use super::suite::SuiteBuilder; +use crate::error::ContractError; +use crate::msg::VoteInfo; + +#[test] +fn add_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[0].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // gauge returns list all options; it does query adapter at initialization + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!(options.len(), 2); + + // Voting members can add options + suite + .add_option(&gauge_contract, voter1, gauge_id, "addedoption1") + .unwrap(); + suite + .add_option(&gauge_contract, voter2, gauge_id, "addedoption2") + .unwrap(); + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // added options are automatically voted for by creators + assert_eq!( + options, + vec![ + ("addedoption1".to_owned(), Uint128::zero()), + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // Non-voting members cannot add options + let err = suite + .add_option(&gauge_contract, "random_voter", gauge_id, "addedoption3") + .unwrap_err(); + assert_eq!( + ContractError::NoVotingPower("random_voter".to_owned()), + err.downcast().unwrap() + ); +} + +fn simple_vote(voter: &str, option: &str, percentage: u64) -> VoteInfo { + VoteInfo { + voter: voter.to_string(), + votes: vec![crate::state::Vote { + option: option.to_string(), + weight: Decimal::percent(percentage), + }], + } +} + +fn multi_vote(voter: &str, votes: &[(&str, u64)]) -> VoteInfo { + let votes = votes + .iter() + .map(|(opt, percentage)| crate::state::Vote { + option: opt.to_string(), + weight: Decimal::percent(*percentage), + }) + .collect(); + VoteInfo { + voter: voter.to_string(), + votes, + } +} + +#[test] +fn vote_for_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[0].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 90), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::new(90))]); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 90), + multi_vote(voter2, &[("option1", 50), ("option2", 50)]), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // placing vote again overwrites previous ones + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![("option1".to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![("option1".to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, "option1", 90), + simple_vote(voter2, "option1", 90), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // vote for non-existing option + let err = suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("random option".to_owned()), + ) + .unwrap_err(); + assert_eq!( + ContractError::OptionDoesNotExists { + option: "random option".to_owned(), + gauge_id + }, + err.downcast().unwrap() + ); +} + +#[test] +fn remove_vote() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[0].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100), + simple_vote(voter2, voter1, 100), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // remove vote + suite + .place_vote(&gauge_contract, voter1.to_owned(), gauge_id, None) + .unwrap(); + assert_eq!( + vec![simple_vote(voter2, voter1, 100)], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + None + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + Some(simple_vote(voter2, voter1, 100)), + ); + + // remove nonexisting vote + let err = suite + .place_vote(&gauge_contract, voter1.to_owned(), gauge_id, None) + .unwrap_err(); + assert_eq!( + ContractError::CannotRemoveNonexistingVote {}, + err.downcast().unwrap() + ); +} diff --git a/contracts/gauge/src/state.rs b/contracts/gauge/src/state.rs index f89b31c5b..d030c6553 100644 --- a/contracts/gauge/src/state.rs +++ b/contracts/gauge/src/state.rs @@ -1,21 +1,35 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Addr, Decimal, StdResult, Storage, Uint128}; -use cw_storage_plus::{Item, Map}; +use cosmwasm_std::{Addr, Decimal, Deps, Order, StdResult, Storage, Uint128}; +use cw_storage_plus::{Bound, Index, IndexList, IndexedMap, Item, Map, MultiIndex}; +use cw_utils::maybe_addr; use crate::msg::VoteInfo; -/// Type alias for u64 to make the map types a bit more self-explanatorys +/// Type alias for u64 to make the map types a bit more self-explanatory pub type GaugeId = u64; pub const CONFIG: Item = Item::new("config"); pub const GAUGES: Map = Map::new("gauges"); +const LAST_ID: Item = Item::new("last_id"); + +/// Get ID for gauge registration and increment value in storage +pub fn fetch_last_id(storage: &mut dyn Storage) -> StdResult { + let last_id = LAST_ID.load(storage).unwrap_or_default(); + LAST_ID.save(storage, &(last_id + 1u64))?; + Ok(last_id) +} /// This lets us find and update any vote given both voter and gauge. -/// It also lets us iterate over all votes by a given voter on all gauges quite easily. -/// This is needed when a voter weight changes in order to update the guage. -/// It makes it very expensive to show all votes on a given gauge, but that is -/// only used for off-chain UI logic anyway, and we should start work on indexers for that. -pub const VOTES: Map<(&Addr, GaugeId), Vote> = Map::new("votes"); +/// It also lets us iterate over all votes by a given voter on all gauges +/// or by a given gauge id. This is needed when a voter weight changes +/// in order to update the guage. +pub fn votes() -> Votes<'static> { + Votes::new("votes", "votes__gaugeid") +} + +// settings for pagination +const MAX_LIMIT: u32 = 100; +const DEFAULT_LIMIT: u32 = 30; #[cw_serde] pub struct Config { @@ -23,17 +37,21 @@ pub struct Config { pub voting_powers: Addr, /// Address that can add new gauges or stop them pub owner: Addr, + /// Address of DAO core module resposible for instantiation and execution of messages + pub dao_core: Addr, } #[cw_serde] pub struct Gauge { + /// Descriptory label of gauge + pub title: String, /// Address of contract to serve gauge-specific info (AdapterQueryMsg) pub adapter: Addr, /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 pub epoch: u64, - /// (Optional) Minimum percentage of votes needed by a given option to be in the selected set + /// Minimum percentage of votes needed by a given option to be in the selected set pub min_percent_selected: Option, - /// (Required) Maximum number of Options to make the selected set. Needed even with + /// Maximum number of Options to make the selected set. Needed even with /// `min_percent_selected` to provide some guarantees on gas usage of this query. pub max_options_selected: u32, /// True if the gauge is stopped @@ -42,20 +60,163 @@ pub struct Gauge { pub next_epoch: u64, } +#[cw_serde] +pub struct WeightedVotes { + /// The gauge these votes are for + pub gauge_id: GaugeId, + /// The voting power behind the vote. + pub power: Uint128, + /// the user's votes for this gauge + pub votes: Vec, +} + +impl Default for WeightedVotes { + fn default() -> Self { + WeightedVotes { + gauge_id: 0, + power: Uint128::zero(), + votes: vec![], + } + } +} + #[cw_serde] pub struct Vote { /// Option voted for. pub option: String, - /// The voting power behind the vote. - pub power: Uint128, + /// The weight of the power given to this vote + pub weight: Decimal, +} + +struct VoteIndexes<'a> { + // Last type param defines the pk deserialization type + pub vote: MultiIndex<'a, GaugeId, WeightedVotes, (&'a Addr, GaugeId)>, +} + +impl<'a> IndexList for VoteIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + Box::new(std::iter::once(&self.vote as &dyn Index)) + } +} + +pub struct Votes<'a> { + // Votes are indexed by `(addr, gauge_id, weight)` triplet + votes: IndexedMap<'a, (&'a Addr, GaugeId), WeightedVotes, VoteIndexes<'a>>, } -/// This can be used on range queries over the votes -pub fn to_vote_info((voter, vote): (Addr, Vote)) -> VoteInfo { - VoteInfo { - voter: voter.into(), - option: vote.option, - power: vote.power, +impl<'a> Votes<'a> { + pub fn new(storage_key: &'a str, vote_subkey: &'a str) -> Self { + let indexes = VoteIndexes { + vote: MultiIndex::new(|_, vote| vote.gauge_id, storage_key, vote_subkey), + }; + let votes = IndexedMap::new(storage_key, indexes); + Self { votes } + } + + pub fn save( + &self, + storage: &mut dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + vote: &WeightedVotes, + ) -> StdResult<()> { + self.votes.save(storage, (voter, gauge_id), vote) + } + + pub fn set_votes( + &self, + storage: &mut dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + votes: Vec, + power: impl Into, + ) -> StdResult<()> { + let power = power.into(); + self.votes.save( + storage, + (voter, gauge_id), + &WeightedVotes { + gauge_id, + power, + votes, + }, + ) + } + + pub fn remove_votes( + &self, + storage: &mut dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult<()> { + self.votes.remove(storage, (voter, gauge_id)) + } + + pub fn load( + &self, + storage: &dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult { + self.votes.load(storage, (voter, gauge_id)) + } + + pub fn may_load( + &self, + storage: &dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult> { + self.votes.may_load(storage, (voter, gauge_id)) + } + + pub fn query_votes_by_voter( + &self, + deps: Deps, + voter_addr: &'a Addr, + start_after: Option, + limit: Option, + ) -> StdResult> { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + + self.votes + .prefix(voter_addr) + .range(deps.storage, start, None, Order::Ascending) + .map(|index| { + let (_, vote) = index?; + Ok(vote) + }) + .take(limit) + .collect() + } + + pub fn query_votes_by_gauge( + &self, + deps: Deps, + gauge_id: GaugeId, + start_after: Option, + limit: Option, + ) -> StdResult> { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let addr = maybe_addr(deps.api, start_after)?; + let start = addr.as_ref().map(|a| Bound::exclusive((a, gauge_id))); + + self.votes + .idx + .vote + .prefix(gauge_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|r| { + let ((voter, _gauge), votes) = r?; + Ok(VoteInfo { + voter: voter.into_string(), + votes: votes.votes, + }) + }) + // NIT: collect and into_iter is a bit inefficient... guess it was too complex/confusing otherwise, so fine + .collect() } } @@ -78,23 +239,44 @@ pub fn update_tally( old_vote: u128, new_vote: u128, ) -> StdResult<()> { - // get old and new values - let old_count = TALLY.may_load(storage, (gauge, option))?; - let count = old_count.unwrap_or_default() + new_vote - old_vote; + update_tallies(storage, gauge, vec![(option, old_vote, new_vote)]) +} + +/// Updates the tally for one option. +/// The first time a user votes, they get `{old_vote: 0, new_vote: power}` +/// If they change options, call old option with `{old_vote: power, new_vote: 0}` and new option with `{old_vote: 0, new_vote: power}` +/// If a user changes power (member update hook), call existing option with `{old_vote: old_power, new_vote: new_power}` +pub fn update_tallies( + storage: &mut dyn Storage, + gauge: GaugeId, + // (option, old, new) + updates: Vec<(&str, u128, u128)>, +) -> StdResult<()> { + let mut old_votes = 0u128; + let mut new_votes = 0u128; + + for (option, old_vote, new_vote) in updates { + old_votes += old_vote; + new_votes += new_vote; + + // get old and new values + let old_count = TALLY.may_load(storage, (gauge, option))?; + let count = old_count.unwrap_or_default() + new_vote - old_vote; - // update main index - TALLY.save(storage, (gauge, option), &count)?; + // update main index + TALLY.save(storage, (gauge, option), &count)?; - // delete old secondary index (if any) - if let Some(old) = old_count { - OPTION_BY_POINTS.remove(storage, (gauge, old, option)); + // delete old secondary index (if any) + if let Some(old) = old_count { + OPTION_BY_POINTS.remove(storage, (gauge, old, option)); + } + // add new secondary index + OPTION_BY_POINTS.save(storage, (gauge, count, option), &1u8)?; } - // add new secondary index - OPTION_BY_POINTS.save(storage, (gauge, count, option), &1u8)?; // update total count let total = TOTAL_CAST.may_load(storage, gauge)?.unwrap_or_default(); - let total = total + new_vote - old_vote; + let total = total + new_votes - old_votes; TOTAL_CAST.save(storage, gauge, &total) } @@ -158,4 +340,239 @@ mod tests { let total = TOTAL_CAST.load(deps.storage, GAUGE).unwrap(); assert_eq!(total, 750u128); } + + fn to_vote_info(voter: &Addr, votes: &[Vote]) -> VoteInfo { + VoteInfo { + voter: voter.to_string(), + votes: votes.to_vec(), + } + } + + #[test] + fn votes_works() { + let mut deps = mock_dependencies(); + let votes = votes(); + + let user1 = Addr::unchecked("user1"); + let votes1 = vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote1 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(3), + votes: votes1.clone(), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 1), &vote1) + .unwrap(); + + let user2 = Addr::unchecked("user2"); + let votes2 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(50), + }]; + let vote2 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(6), + votes: votes2.clone(), + }; + votes + .votes + .save(&mut deps.storage, (&user2, 1), &vote2) + .unwrap(); + + let user3 = Addr::unchecked("user3"); + let votes3 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(70), + }]; + let vote3 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(9), + votes: votes3.clone(), + }; + votes + .votes + .save(&mut deps.storage, (&user3, 1), &vote3) + .unwrap(); + + let votes4 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(75), + }]; + let vote4 = WeightedVotes { + gauge_id: 2, + power: Uint128::new(12), + votes: votes4, + }; + votes + .votes + .save(&mut deps.storage, (&user1, 2), &vote4) + .unwrap(); + + let votes5 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote5 = WeightedVotes { + gauge_id: 3, + power: Uint128::new(15), + votes: votes5, + }; + votes + .votes + .save(&mut deps.storage, (&user1, 3), &vote5) + .unwrap(); + + let vote_result = votes.votes.load(&deps.storage, (&user2, 1)).unwrap(); + assert_eq!(vote_result, vote2); + + let result = votes + .query_votes_by_gauge(deps.as_ref(), 1, None, None) + .unwrap(); + assert_eq!( + result, + vec![ + to_vote_info(&user1, &votes1), + to_vote_info(&user2, &votes2), + to_vote_info(&user3, &votes3), + ] + ); + + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, None, None) + .unwrap(); + assert_eq!(result, vec![vote1, vote4, vote5]); + } + + #[test] + fn query_votes_by_gauge_paginated() { + let mut deps = mock_dependencies(); + let votes = votes(); + + let gauge_id = 1; + + let user1 = Addr::unchecked("user1"); + let votes1 = vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote1 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(3), + votes: votes1.clone(), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 1), &vote1) + .unwrap(); + + let user2 = Addr::unchecked("user2"); + let votes2 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(50), + }]; + let vote2 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(6), + votes: votes2.clone(), + }; + votes + .votes + .save(&mut deps.storage, (&user2, 1), &vote2) + .unwrap(); + + let user3 = Addr::unchecked("user3"); + let votes3 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(70), + }]; + let vote3 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(9), + votes: votes3.clone(), + }; + votes + .votes + .save(&mut deps.storage, (&user3, 1), &vote3) + .unwrap(); + + // limit to 2 results + let result = votes + .query_votes_by_gauge(deps.as_ref(), gauge_id, None, Some(2)) + .unwrap(); + assert_eq!( + result, + vec![to_vote_info(&user1, &votes1), to_vote_info(&user2, &votes2)] + ); + + // start from second user (start_after user1) + let result = votes + .query_votes_by_gauge(deps.as_ref(), gauge_id, Some(user1.to_string()), None) + .unwrap(); + assert_eq!( + result, + vec![to_vote_info(&user2, &votes2), to_vote_info(&user3, &votes3)] + ); + } + + #[test] + fn query_votes_by_user_paginated() { + let mut deps = mock_dependencies(); + let votes = votes(); + let user1 = Addr::unchecked("user1"); + + let vote1 = WeightedVotes { + gauge_id: 2, + power: Uint128::new(3), + votes: vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }], + }; + votes + .votes + .save(&mut deps.storage, (&user1, 2), &vote1) + .unwrap(); + + let vote2 = WeightedVotes { + gauge_id: 3, + power: Uint128::new(6), + votes: vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }], + }; + votes + .votes + .save(&mut deps.storage, (&user1, 3), &vote2) + .unwrap(); + + let vote3 = WeightedVotes { + gauge_id: 4, + power: Uint128::new(9), + votes: vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }], + }; + votes + .votes + .save(&mut deps.storage, (&user1, 4), &vote3) + .unwrap(); + + // limit to 2 results + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, None, Some(2)) + .unwrap(); + assert_eq!(result, vec![vote1, vote2.clone()]); + + // start from second user (start_after gauge_id 2) + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, Some(2), None) + .unwrap(); + assert_eq!(result, vec![vote2, vote3]); + } } From 5040c999772252a528dd4a25ede12af8ca6e88ba Mon Sep 17 00:00:00 2001 From: Jakub Bogucki Date: Tue, 21 Feb 2023 15:31:00 +0100 Subject: [PATCH 03/12] Update contracts to version 1.5.4 --- contracts/gauge/src/contract.rs | 128 +++++- contracts/gauge/src/error.rs | 9 + contracts/gauge/src/msg.rs | 24 +- contracts/gauge/src/multitest/gauge.rs | 544 +++++++++++++++++++++++- contracts/gauge/src/multitest/suite.rs | 84 +++- contracts/gauge/src/multitest/voting.rs | 91 ++++ contracts/gauge/src/state.rs | 2 + 7 files changed, 829 insertions(+), 53 deletions(-) diff --git a/contracts/gauge/src/contract.rs b/contracts/gauge/src/contract.rs index e8ac9aa91..f0a6fe749 100644 --- a/contracts/gauge/src/contract.rs +++ b/contracts/gauge/src/contract.rs @@ -1,8 +1,8 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - to_binary, Addr, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Order, QueryRequest, - Response, StdResult, Uint128, WasmMsg, WasmQuery, + ensure, to_binary, Addr, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Order, QueryRequest, + Response, StdError, StdResult, Uint128, WasmMsg, WasmQuery, }; use cw2::set_contract_version; use cw_core_interface::{ @@ -68,6 +68,19 @@ pub fn execute( execute::member_changed(deps, info.sender, hook_msg.diffs) } ExecuteMsg::CreateGauge(options) => execute::create_gauge(deps, env, info.sender, options), + ExecuteMsg::UpdateGauge { + gauge_id, + epoch_size, + min_percent_selected, + max_options_selected, + } => execute::update_gauge( + deps, + info.sender, + gauge_id, + epoch_size, + min_percent_selected, + max_options_selected, + ), ExecuteMsg::StopGauge { gauge } => execute::stop_gauge(deps, info.sender, gauge), ExecuteMsg::AddOption { gauge, option } => { execute::add_option(deps, info.sender, gauge, option, true) @@ -162,6 +175,18 @@ mod execute { }: GaugeConfig, ) -> Result { let adapter = deps.api.addr_validate(&adapter)?; + // gauge parameter validation + ensure!(epoch_size > 60u64, ContractError::EpochSizeTooShort {}); + if let Some(min_percent_selected) = min_percent_selected { + ensure!( + min_percent_selected < Decimal::one(), + ContractError::MinPercentSelectedTooBig {} + ); + } + ensure!( + max_options_selected > 0, + ContractError::MaxOptionsSelectedTooSmall {} + ); let gauge = Gauge { title, adapter: adapter.clone(), @@ -170,6 +195,7 @@ mod execute { max_options_selected, is_stopped: false, next_epoch: env.block.time.seconds() + epoch_size, + last_executed_set: None, }; let last_id: GaugeId = fetch_last_id(deps.storage)?; GAUGES.save(deps.storage, last_id, &gauge)?; @@ -188,6 +214,47 @@ mod execute { Ok(adapter) } + pub fn update_gauge( + deps: DepsMut, + sender: Addr, + gauge_id: u64, + epoch_size: Option, + min_percent_selected: Option, + max_options_selected: Option, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; + if let Some(epoch_size) = epoch_size { + ensure!(epoch_size > 60u64, ContractError::EpochSizeTooShort {}); + gauge.epoch = epoch_size; + } + if let Some(min_percent_selected) = min_percent_selected { + if min_percent_selected.is_zero() { + gauge.min_percent_selected = None + } else { + ensure!( + min_percent_selected < Decimal::one(), + ContractError::MinPercentSelectedTooBig {} + ); + gauge.min_percent_selected = Some(min_percent_selected) + }; + } + if let Some(max_options_selected) = max_options_selected { + ensure!( + max_options_selected > 0, + ContractError::MaxOptionsSelectedTooSmall {} + ); + gauge.max_options_selected = max_options_selected; + } + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new().add_attribute("action", "update_gauge")) + } + pub fn stop_gauge( deps: DepsMut, sender: Addr, @@ -362,7 +429,7 @@ mod execute { } pub fn execute(deps: DepsMut, env: Env, gauge_id: u64) -> Result { - let gauge = GAUGES.load(deps.storage, gauge_id)?; + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; if gauge.is_stopped { return Err(ContractError::GaugeStopped(gauge_id)); @@ -376,27 +443,19 @@ mod execute { next_epoch: gauge.next_epoch, }); } + gauge.next_epoch = env.block.time.plus_seconds(gauge.epoch).seconds(); // this set contains tuple (option, total_voted_power) // for adapter query, this needs to be transformed into (option, voted_weight) let selected_set_with_powers = query::selected_set(deps.as_ref(), gauge_id)?.votes; - - // This is a bit hacky solution to accomplish 3 things: - // - remove executed option from TALLY - // - remove executed option from OPTION_BY_POINTS - // - summarizing all power to be subtracted from TOTAL_CAST - // Placed here to avoid copy of either whole iterator or its elements in second loop - // down below. let selected_powers_sum = selected_set_with_powers .iter() - .map(|(option, power)| { - let power = power.u128(); - TALLY.remove(deps.storage, (gauge_id, option)); - OPTION_BY_POINTS.remove(deps.storage, (gauge_id, power, option)); - power - }) + .map(|(_, power)| power.u128()) .sum::(); + // save the selected options and their powers for the frontend to display + gauge.last_executed_set = Some(selected_set_with_powers.clone()); + // calculate "local" ratios of voted options per total power of all selected options let selected = selected_set_with_powers .into_iter() @@ -405,7 +464,7 @@ mod execute { // query gauge adapter for execute messages for DAO let execute_messages: SampleGaugeMsgsResponse = deps.querier.query_wasm_smart( - gauge.adapter, + gauge.adapter.clone(), &AdapterQueryMsg::SampleGaugeMsgs { selected }, )?; @@ -418,10 +477,7 @@ mod execute { funds: vec![], }; - // update total cast to reflect executed messages - TOTAL_CAST.update(deps.storage, gauge_id, |total_cast| -> StdResult<_> { - Ok(total_cast.unwrap_or_default() - selected_powers_sum) - })?; + GAUGES.save(deps.storage, gauge_id, &gauge)?; Ok(Response::new() .add_attribute("action", "execute_tally") @@ -459,13 +515,16 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { limit, )?)?), QueryMsg::SelectedSet { gauge } => Ok(to_binary(&query::selected_set(deps, gauge)?)?), + QueryMsg::LastExecutedSet { gauge } => { + Ok(to_binary(&query::last_executed_set(deps, gauge)?)?) + } } } mod query { use super::*; - use crate::msg::{VoteInfo, VoteResponse}; + use crate::msg::{LastExecutedSetResponse, VoteInfo, VoteResponse}; use cw_core_interface::voting::InfoResponse; pub fn info(deps: Deps) -> StdResult { @@ -586,10 +645,33 @@ mod query { Ok(SelectedSetResponse { votes }) } + + pub fn last_executed_set(deps: Deps, gauge_id: u64) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + Ok(LastExecutedSetResponse { + votes: gauge.last_executed_set, + }) + } } #[cfg_attr(not(feature = "library"), entry_point)] -pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { +pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg) -> Result { ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + for (gauge_id, next_epoch) in msg.next_epochs.unwrap_or_default() { + GAUGES.update(deps.storage, gauge_id, |gauge| -> StdResult<_> { + let mut gauge = gauge.ok_or(StdError::NotFound { + kind: format!("Gauge with id {}", gauge_id), + })?; + if next_epoch < env.block.time.seconds() { + return Err(StdError::GenericErr { + msg: "Next epoch value cannot be earlier then current epoch!".to_owned(), + }); + } + gauge.next_epoch = next_epoch; + Ok(gauge) + })?; + } + Ok(Response::new()) } diff --git a/contracts/gauge/src/error.rs b/contracts/gauge/src/error.rs index 241867d9c..2ee7db2ff 100644 --- a/contracts/gauge/src/error.rs +++ b/contracts/gauge/src/error.rs @@ -39,4 +39,13 @@ pub enum ContractError { #[error("Trying to remove vote that does not exists")] CannotRemoveNonexistingVote {}, + + #[error("Epoch size must be bigger then 60 seconds")] + EpochSizeTooShort {}, + + #[error("Minimum percent selected parameter needs to be smaller then 1.0")] + MinPercentSelectedTooBig {}, + + #[error("Maximum options selected parameter needs to be bigger then 0")] + MaxOptionsSelectedTooSmall {}, } diff --git a/contracts/gauge/src/msg.rs b/contracts/gauge/src/msg.rs index 4f925a04b..7974cf9fc 100644 --- a/contracts/gauge/src/msg.rs +++ b/contracts/gauge/src/msg.rs @@ -4,6 +4,8 @@ use cosmwasm_std::{CosmosMsg, Decimal, Uint128}; use crate::state::Vote; use wynd_stake::hook::MemberChangedHookMsg; +type GaugeId = u64; + #[cw_serde] pub struct InstantiateMsg { /// Address of contract to that contains all voting powers (where we query and listen to hooks) @@ -40,6 +42,15 @@ pub enum ExecuteMsg { /// This creates a new Gauge, returns CreateGaugeReply JSON-encoded in the data field. /// Can only be called by owner CreateGauge(GaugeConfig), + /// Allows owner to update certain parameters of GaugeConfig. + /// If you want to change next_epoch value, you need to use migration. + UpdateGauge { + gauge_id: u64, + epoch_size: Option, + // Some<0> would set min_percent_selected to None + min_percent_selected: Option, + max_options_selected: Option, + }, /// Stops a given gauge, meaning it will not execute any more messages, /// Or receive any more updates on MemberChangedHook. /// Ideally, this will allow for eventual deletion of all data on that gauge @@ -97,6 +108,8 @@ pub enum QueryMsg { }, #[returns(SelectedSetResponse)] SelectedSet { gauge: u64 }, + #[returns(LastExecutedSetResponse)] + LastExecutedSet { gauge: u64 }, } /// Information about one gauge @@ -157,6 +170,13 @@ pub struct ListOptionsResponse { pub options: Vec<(String, Uint128)>, } +/// List the options that were selected in the last executed set. +#[cw_serde] +pub struct LastExecutedSetResponse { + /// `None` if no vote has been executed yet + pub votes: Option>, +} + /// List the top options by power that would make it into the selected set. /// Ordered from highest votes to lowest #[cw_serde] @@ -197,4 +217,6 @@ pub struct SampleGaugeMsgsResponse { } #[cw_serde] -pub enum MigrateMsg {} +pub struct MigrateMsg { + pub next_epochs: Option>, +} diff --git a/contracts/gauge/src/multitest/gauge.rs b/contracts/gauge/src/multitest/gauge.rs index 82a542100..742388c75 100644 --- a/contracts/gauge/src/multitest/gauge.rs +++ b/contracts/gauge/src/multitest/gauge.rs @@ -1,7 +1,7 @@ -use cosmwasm_std::{Decimal, Uint128}; +use cosmwasm_std::{Addr, Decimal, Uint128}; use voting::Vote; -use super::suite::SuiteBuilder; +use super::suite::{Suite, SuiteBuilder}; use crate::error::ContractError; use crate::msg::GaugeResponse; @@ -16,38 +16,52 @@ fn create_gauge() { .with_voting_members(&[(voter1, 100), (voter2, 100)]) .build(); - suite.next_block(); - suite - .propose_update_proposal_module(voter1.to_string(), None) - .unwrap(); + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); - suite.next_block(); - let proposal = suite.list_proposals().unwrap()[0]; - suite - .place_vote_single(voter1, proposal, Vote::Yes) - .unwrap(); - suite - .place_vote_single(voter2, proposal, Vote::Yes) + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + ) .unwrap(); - suite.next_block(); - suite - .execute_single_proposal(voter1.to_string(), proposal) - .unwrap(); - let proposal_modules = suite.query_proposal_modules().unwrap(); + let response = suite.query_gauge(gauge_contract, 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + } + ); +} - // Second proposal module is cw proposal single, first one is newly added gauge - assert_eq!(proposal_modules.len(), 2); - let gauge_contract = proposal_modules[0].clone(); +#[test] +fn gauge_can_upgrade_from_self() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1]); let gauge_adapter = suite .instantiate_adapter_and_create_gauge( gauge_contract.clone(), - &[voter1, voter2], + &["option1", "option2"], (1000, "ujuno"), ) .unwrap(); + // now let's migrate the gauge and make sure nothing breaks + suite.auto_migrate_gauge(&gauge_contract, None).unwrap(); + let response = suite.query_gauge(gauge_contract, 0).unwrap(); assert_eq!( response, @@ -64,6 +78,75 @@ fn create_gauge() { ); } +#[test] +fn gauge_migrate_with_next_epochs() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + ) + .unwrap(); + + // previous settings + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + } + ); + + // now let's migrate the gauge and make sure nothing breaks + let gauge_id = 0; + // change next epoch from 7 to 14 days + suite + .auto_migrate_gauge( + &gauge_contract, + vec![(gauge_id, suite.current_time() + 14 * 86400)], + ) + .unwrap(); + + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + is_stopped: false, + next_epoch: suite.current_time() + 14 * 86400, + } + ); + + // try to migrate updating next epoch on nonexisting gauge_id + // actually generic error makes it more difficult to debug in presentable form, I think this is + // enough + let _err = suite + .auto_migrate_gauge( + &gauge_contract, + vec![(420, suite.current_time() + 14 * 86400)], + ) + .unwrap_err(); +} + /// attach adaptor in instantiate #[test] fn execute_gauge() { @@ -136,6 +219,239 @@ fn execute_gauge() { ); } +/// Small helper method to setup the gauge contract. +/// Make sure that `voter` has voting power. +fn init_gauge(suite: &mut Suite, voters: &[&str]) -> Addr { + suite.next_block(); + suite + .propose_update_proposal_module(voters[0], None) + .unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + for voter in voters { + suite + .place_vote_single(*voter, proposal, Vote::Yes) + .unwrap(); + } + suite.next_block(); + suite.execute_single_proposal(voters[0], proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + // Second proposal module is cw proposal single, first one is newly added gauge + assert_eq!(proposal_modules.len(), 2); + proposal_modules[0].clone() +} + +#[test] +fn query_last_execution() { + let voter1 = "voter1"; + let voter2 = "voter2"; + + let reward_to_distribute = (2000, "ujuno"); + + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2, gauge_contract.as_str()], + (1000, "ujuno"), + ) + .unwrap(); + let gauge_id = 0; + + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + None, + "not executed yet" + ); + + // vote + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter1.to_owned())) + .unwrap(); + suite + .place_votes( + &gauge_contract, + voter2, + gauge_id, + vec![ + (gauge_contract.to_string(), Decimal::percent(40)), + (voter2.to_owned(), Decimal::percent(60)), + ], + ) + .unwrap(); + // wait until epoch passes + suite.advance_time(EPOCH); + // execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // should return the executed set now + let expected_votes = Some(vec![ + (voter1.to_owned(), 100u128.into()), + (voter2.to_string(), 60u128.into()), + (gauge_contract.to_string(), 40u128.into()), + ]); + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + expected_votes + ); + + // change votes + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter2.to_owned())) + .unwrap(); + suite + .place_vote(&gauge_contract, voter2, gauge_id, None) + .unwrap(); + + // wait until epoch passes + suite.advance_time(EPOCH); + + // should not change last execution yet + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + expected_votes + ); + + // execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // now it should be changed + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + Some(vec![(voter2.to_owned(), 100u128.into())]) + ); +} + +#[test] +fn execute_gauge_twice_same_epoch() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], (1000, "ujuno")) // reward per + // epoch + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 1000u128 + ); + + // execution twice same time won't work + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + let next_epoch = suite.current_time() + EPOCH; + assert_eq!( + ContractError::EpochNotReached { + gauge_id, + current_epoch: suite.current_time(), + next_epoch + }, + err.downcast().unwrap() + ); + + // just before next epoch fails as well + suite.advance_time(EPOCH - 1); + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!( + ContractError::EpochNotReached { + gauge_id, + current_epoch: suite.current_time(), + next_epoch + }, + err.downcast().unwrap() + ); + + // another epoch is fine + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 2000u128 + ); +} + #[test] fn execute_stopped_gauge() { let voter1 = "voter1"; @@ -216,3 +532,187 @@ fn execute_stopped_gauge() { err.downcast().unwrap() ); } + +#[test] +fn update_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + ) + .unwrap(); + + let second_gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "uusdc"), + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + } + ] + ); + + // update parameters on the first gauge + let owner = suite.owner.clone(); + let new_epoch = EPOCH * 2; + let new_min_percent = Some(Decimal::percent(10)); + let new_max_options = 15; + suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + new_max_options, + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: new_epoch, + min_percent_selected: new_min_percent, + max_options_selected: new_max_options, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + } + ] + ); + + // clean setting of min_percent_selected on second gauge + suite + .update_gauge( + &owner, + gauge_contract.clone(), + 1, + None, + Some(Decimal::zero()), + None, + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: new_epoch, + min_percent_selected: new_min_percent, + max_options_selected: new_max_options, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: None, + max_options_selected: 10, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + } + ] + ); + + // Not owner cannot update gauges + let err = suite + .update_gauge( + "notowner", + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + new_max_options, + ) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + let err = suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + 50, + new_min_percent, + new_max_options, + ) + .unwrap_err(); + assert_eq!(ContractError::EpochSizeTooShort {}, err.downcast().unwrap()); + + let err = suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + Some(Decimal::one()), + new_max_options, + ) + .unwrap_err(); + assert_eq!( + ContractError::MinPercentSelectedTooBig {}, + err.downcast().unwrap() + ); + + let err = suite + .update_gauge(&owner, gauge_contract, 0, new_epoch, new_min_percent, 0) + .unwrap_err(); + assert_eq!( + ContractError::MaxOptionsSelectedTooSmall {}, + err.downcast().unwrap() + ); +} diff --git a/contracts/gauge/src/multitest/suite.rs b/contracts/gauge/src/multitest/suite.rs index c9742baa6..a1909430c 100644 --- a/contracts/gauge/src/multitest/suite.rs +++ b/contracts/gauge/src/multitest/suite.rs @@ -18,18 +18,24 @@ use voting::{PercentageThreshold, Threshold, Vote}; use super::adapter::{contract as adapter_contract, InstantiateMsg as AdapterInstantiateMsg}; use crate::msg::{ - ExecuteMsg, GaugeConfig, GaugeResponse, InstantiateMsg, ListOptionsResponse, ListVotesResponse, - QueryMsg, SelectedSetResponse, VoteInfo, VoteResponse, + ExecuteMsg, GaugeConfig, GaugeResponse, InstantiateMsg, LastExecutedSetResponse, + ListGaugesResponse, ListOptionsResponse, ListVotesResponse, MigrateMsg, QueryMsg, + SelectedSetResponse, VoteInfo, VoteResponse, }; +type GaugeId = u64; + pub const BLOCK_TIME: u64 = 5; fn store_gauge(app: &mut App) -> u64 { - let contract = Box::new(ContractWrapper::new_with_empty( - crate::contract::execute, - crate::contract::instantiate, - crate::contract::query, - )); + let contract = Box::new( + ContractWrapper::new_with_empty( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate), + ); app.store_code(contract) } @@ -336,6 +342,20 @@ impl Suite { .query_wasm_smart(gauge_contract, &QueryMsg::Gauge { id }) } + pub fn query_gauges(&self, gauge_contract: Addr) -> StdResult> { + Ok(self + .app + .wrap() + .query_wasm_smart::( + gauge_contract, + &QueryMsg::ListGauges { + start_after: None, + limit: None, + }, + )? + .gauges) + } + pub fn query_selected_set( &self, gauge_contract: &Addr, @@ -348,6 +368,18 @@ impl Suite { Ok(set.votes) } + pub fn query_last_executed_set( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult>> { + let set: LastExecutedSetResponse = self + .app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::LastExecutedSet { gauge: id })?; + Ok(set.votes) + } + pub fn query_list_options( &self, gauge_contract: &Addr, @@ -473,6 +505,28 @@ impl Suite { }) } + pub fn update_gauge( + &mut self, + sender: &str, + gauge_contract: Addr, + gauge_id: u64, + epoch_size: impl Into>, + min_percent_selected: Option, + max_options_selected: impl Into>, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge_contract, + &ExecuteMsg::UpdateGauge { + gauge_id, + epoch_size: epoch_size.into(), + min_percent_selected, + max_options_selected: max_options_selected.into(), + }, + &[], + ) + } + pub fn place_vote_single( &mut self, voter: impl Into, @@ -525,4 +579,20 @@ impl Suite { let balance = self.app.wrap().query_balance(account, denom)?; Ok(balance.amount.u128()) } + + pub fn auto_migrate_gauge( + &mut self, + gauge: &Addr, + next_epochs: impl Into>>, + ) -> AnyResult { + let sender = Addr::unchecked(&self.owner); + self.app.migrate_contract( + sender, + gauge.clone(), + &MigrateMsg { + next_epochs: next_epochs.into(), + }, + self.gauge_code_id, + ) + } } diff --git a/contracts/gauge/src/multitest/voting.rs b/contracts/gauge/src/multitest/voting.rs index 1ea48d1b1..9f2c22ee1 100644 --- a/contracts/gauge/src/multitest/voting.rs +++ b/contracts/gauge/src/multitest/voting.rs @@ -5,6 +5,8 @@ use super::suite::SuiteBuilder; use crate::error::ContractError; use crate::msg::VoteInfo; +const EPOCH: u64 = 7 * 86_400; + #[test] fn add_option() { let voter1 = "voter1"; @@ -325,3 +327,92 @@ fn remove_vote() { err.downcast().unwrap() ); } + +#[test] +fn votes_stays_the_same_after_execution() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100), + simple_vote(voter2, voter1, 100) + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100), + simple_vote(voter2, voter1, 100) + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + Some(simple_vote(voter1, voter1, 100)), + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + Some(simple_vote(voter2, voter1, 100)), + ); +} diff --git a/contracts/gauge/src/state.rs b/contracts/gauge/src/state.rs index d030c6553..b804b3196 100644 --- a/contracts/gauge/src/state.rs +++ b/contracts/gauge/src/state.rs @@ -58,6 +58,8 @@ pub struct Gauge { pub is_stopped: bool, /// UNIX time (seconds) when next epoch can be executed. If < env.block.time then Execute can be called pub next_epoch: u64, + /// The last set of options selected by the gauge, `None` before the first execution + pub last_executed_set: Option>, } #[cw_serde] From 62a146fd21ea24adfb9083c9c5d4c018cc1ec78d Mon Sep 17 00:00:00 2001 From: Jakub Bogucki Date: Thu, 9 Mar 2023 19:09:39 +0100 Subject: [PATCH 04/12] Set version v1.6.0 --- contracts/gauge/Cargo.toml | 3 + contracts/gauge/src/contract.rs | 49 ++++++++++++- contracts/gauge/src/error.rs | 3 + contracts/gauge/src/msg.rs | 5 ++ contracts/gauge/src/multitest/gauge.rs | 54 +++++++++++++- contracts/gauge/src/multitest/suite.rs | 12 ++- contracts/gauge/src/multitest/tally.rs | 5 +- contracts/gauge/src/multitest/voting.rs | 97 ++++++++++++++++++++++++- contracts/gauge/src/state.rs | 2 + 9 files changed, 222 insertions(+), 8 deletions(-) diff --git a/contracts/gauge/Cargo.toml b/contracts/gauge/Cargo.toml index 308f80da7..7f9324923 100644 --- a/contracts/gauge/Cargo.toml +++ b/contracts/gauge/Cargo.toml @@ -22,6 +22,9 @@ schemars = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } wynd-stake = { workspace = true } +# Temporarily added for migration tests +semver = { workspace = true } +gauge-orchestrator-1_5_3 = { git = "https://github.com/cosmorama/wynddao.git", tag="v1.5.3", package = "gauge-orchestrator" } [dev-dependencies] anyhow = { workspace = true } diff --git a/contracts/gauge/src/contract.rs b/contracts/gauge/src/contract.rs index f0a6fe749..32474db86 100644 --- a/contracts/gauge/src/contract.rs +++ b/contracts/gauge/src/contract.rs @@ -24,6 +24,8 @@ use crate::state::{ TALLY, TOTAL_CAST, }; +use semver::Version; + // version info for migration info const CONTRACT_NAME: &str = "crates.io:gauge"; const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -73,6 +75,7 @@ pub fn execute( epoch_size, min_percent_selected, max_options_selected, + max_available_percentage, } => execute::update_gauge( deps, info.sender, @@ -80,6 +83,7 @@ pub fn execute( epoch_size, min_percent_selected, max_options_selected, + max_available_percentage, ), ExecuteMsg::StopGauge { gauge } => execute::stop_gauge(deps, info.sender, gauge), ExecuteMsg::AddOption { gauge, option } => { @@ -172,6 +176,7 @@ mod execute { epoch_size, min_percent_selected, max_options_selected, + max_available_percentage, }: GaugeConfig, ) -> Result { let adapter = deps.api.addr_validate(&adapter)?; @@ -193,6 +198,7 @@ mod execute { epoch: epoch_size, min_percent_selected, max_options_selected, + max_available_percentage, is_stopped: false, next_epoch: env.block.time.seconds() + epoch_size, last_executed_set: None, @@ -221,6 +227,7 @@ mod execute { epoch_size: Option, min_percent_selected: Option, max_options_selected: Option, + max_available_percentage: Option, ) -> Result { let config = CONFIG.load(deps.storage)?; if sender != config.owner { @@ -250,6 +257,17 @@ mod execute { ); gauge.max_options_selected = max_options_selected; } + if let Some(max_available_percentage) = max_available_percentage { + if max_available_percentage.is_zero() { + gauge.max_available_percentage = None + } else { + ensure!( + max_available_percentage < Decimal::one(), + ContractError::MaxAvailablePercentTooBig {} + ); + gauge.max_available_percentage = Some(max_available_percentage) + }; + } GAUGES.save(deps.storage, gauge_id, &gauge)?; Ok(Response::new().add_attribute("action", "update_gauge")) @@ -540,6 +558,7 @@ mod query { epoch_size: gauge.epoch, min_percent_selected: gauge.min_percent_selected, max_options_selected: gauge.max_options_selected, + max_available_percentage: gauge.max_available_percentage, is_stopped: gauge.is_stopped, next_epoch: gauge.next_epoch, } @@ -638,6 +657,14 @@ mod query { }) .map(|o| { let ((power, option), _) = o?; + // If gauge has max_available_percentage set, discard all power + // above that percentage + if let Some(max_available_percentage) = gauge.max_available_percentage { + if Decimal::from_ratio(power, total_cast) > max_available_percentage { + // If power is above available percentage, cut power down to max available + return Ok((option, Uint128::new(total_cast) * max_available_percentage)); + } + } Ok((option, Uint128::new(power))) }) .take(gauge.max_options_selected as usize) @@ -656,7 +683,7 @@ mod query { #[cfg_attr(not(feature = "library"), entry_point)] pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg) -> Result { - ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + let version = ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; for (gauge_id, next_epoch) in msg.next_epochs.unwrap_or_default() { GAUGES.update(deps.storage, gauge_id, |gauge| -> StdResult<_> { @@ -673,5 +700,25 @@ pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg) -> Result().unwrap() { + use cw_storage_plus::Map; + let old_storage: Map = Map::new("gauges"); + let gauge = old_storage.load(deps.storage, 0)?; + let new_gauge = Gauge { + title: gauge.title, + adapter: gauge.adapter, + epoch: gauge.epoch, + min_percent_selected: gauge.min_percent_selected, + max_options_selected: gauge.max_options_selected, + max_available_percentage: None, + is_stopped: gauge.is_stopped, + next_epoch: gauge.next_epoch, + // this is fine, it will be overwritten on next gauge execution + last_executed_set: None, + }; + GAUGES.save(deps.storage, 0, &new_gauge)?; + } + Ok(Response::new()) } diff --git a/contracts/gauge/src/error.rs b/contracts/gauge/src/error.rs index 2ee7db2ff..1ad1edea4 100644 --- a/contracts/gauge/src/error.rs +++ b/contracts/gauge/src/error.rs @@ -48,4 +48,7 @@ pub enum ContractError { #[error("Maximum options selected parameter needs to be bigger then 0")] MaxOptionsSelectedTooSmall {}, + + #[error("Maximum percentage available parameter needs to be smaller then 1.0")] + MaxAvailablePercentTooBig {}, } diff --git a/contracts/gauge/src/msg.rs b/contracts/gauge/src/msg.rs index 7974cf9fc..1f4ccde64 100644 --- a/contracts/gauge/src/msg.rs +++ b/contracts/gauge/src/msg.rs @@ -32,6 +32,8 @@ pub struct GaugeConfig { /// Maximum number of Options to make the selected set. Needed even with /// `min_percent_selected` to provide some guarantees on gas usage of this query. pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, } #[cw_serde] @@ -50,6 +52,7 @@ pub enum ExecuteMsg { // Some<0> would set min_percent_selected to None min_percent_selected: Option, max_options_selected: Option, + max_available_percentage: Option, }, /// Stops a given gauge, meaning it will not execute any more messages, /// Or receive any more updates on MemberChangedHook. @@ -128,6 +131,8 @@ pub struct GaugeResponse { /// Maximum number of Options to make the selected set. Needed even with /// `min_percent_selected` to provide some guarantees on gas usage of this query. pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, /// True if the gauge is stopped pub is_stopped: bool, /// UNIX time (seconds) when next epoch may be executed. May be future or past diff --git a/contracts/gauge/src/multitest/gauge.rs b/contracts/gauge/src/multitest/gauge.rs index 742388c75..1a9d17855 100644 --- a/contracts/gauge/src/multitest/gauge.rs +++ b/contracts/gauge/src/multitest/gauge.rs @@ -23,6 +23,7 @@ fn create_gauge() { gauge_contract.clone(), &[voter1, voter2], (1000, "ujuno"), + None, ) .unwrap(); @@ -36,6 +37,7 @@ fn create_gauge() { epoch_size: EPOCH, min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, + max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, } @@ -56,6 +58,7 @@ fn gauge_can_upgrade_from_self() { gauge_contract.clone(), &["option1", "option2"], (1000, "ujuno"), + None, ) .unwrap(); @@ -72,6 +75,7 @@ fn gauge_can_upgrade_from_self() { epoch_size: EPOCH, min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, + max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, } @@ -92,6 +96,7 @@ fn gauge_migrate_with_next_epochs() { gauge_contract.clone(), &["option1", "option2"], (1000, "ujuno"), + None, ) .unwrap(); @@ -106,6 +111,7 @@ fn gauge_migrate_with_next_epochs() { epoch_size: EPOCH, min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, + max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, } @@ -131,6 +137,7 @@ fn gauge_migrate_with_next_epochs() { epoch_size: EPOCH, min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, + max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 14 * 86400, } @@ -160,7 +167,7 @@ fn execute_gauge() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute) + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None) .unwrap(); suite .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) @@ -261,6 +268,7 @@ fn query_last_execution() { gauge_contract.clone(), &[voter1, voter2, gauge_contract.as_str()], (1000, "ujuno"), + None, ) .unwrap(); let gauge_id = 0; @@ -353,7 +361,7 @@ fn execute_gauge_twice_same_epoch() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&[voter1, voter2], (1000, "ujuno")) // reward per + .instantiate_adapter_and_return_config(&[voter1, voter2], (1000, "ujuno"), None) // reward per // epoch .unwrap(); suite @@ -464,7 +472,7 @@ fn execute_stopped_gauge() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute) + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None) .unwrap(); suite .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) @@ -548,6 +556,7 @@ fn update_gauge() { gauge_contract.clone(), &[voter1, voter2], (1000, "ujuno"), + None, ) .unwrap(); @@ -556,6 +565,7 @@ fn update_gauge() { gauge_contract.clone(), &[voter1, voter2], (1000, "uusdc"), + None, ) .unwrap(); @@ -570,6 +580,7 @@ fn update_gauge() { epoch_size: EPOCH, min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, + max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, }, @@ -580,6 +591,7 @@ fn update_gauge() { epoch_size: EPOCH, min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, + max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, } @@ -591,6 +603,7 @@ fn update_gauge() { let new_epoch = EPOCH * 2; let new_min_percent = Some(Decimal::percent(10)); let new_max_options = 15; + let new_max_available_percentage = Some(Decimal::percent(5)); suite .update_gauge( &owner, @@ -599,6 +612,7 @@ fn update_gauge() { new_epoch, new_min_percent, new_max_options, + new_max_available_percentage, ) .unwrap(); @@ -613,6 +627,7 @@ fn update_gauge() { epoch_size: new_epoch, min_percent_selected: new_min_percent, max_options_selected: new_max_options, + max_available_percentage: new_max_available_percentage, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, }, @@ -623,6 +638,7 @@ fn update_gauge() { epoch_size: EPOCH, min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, + max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, } @@ -638,6 +654,7 @@ fn update_gauge() { None, Some(Decimal::zero()), None, + None, ) .unwrap(); @@ -652,6 +669,7 @@ fn update_gauge() { epoch_size: new_epoch, min_percent_selected: new_min_percent, max_options_selected: new_max_options, + max_available_percentage: new_max_available_percentage, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, }, @@ -662,6 +680,7 @@ fn update_gauge() { epoch_size: EPOCH, min_percent_selected: None, max_options_selected: 10, + max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, } @@ -677,6 +696,7 @@ fn update_gauge() { new_epoch, new_min_percent, new_max_options, + None, ) .unwrap_err(); assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); @@ -689,6 +709,7 @@ fn update_gauge() { 50, new_min_percent, new_max_options, + None, ) .unwrap_err(); assert_eq!(ContractError::EpochSizeTooShort {}, err.downcast().unwrap()); @@ -701,6 +722,7 @@ fn update_gauge() { new_epoch, Some(Decimal::one()), new_max_options, + None, ) .unwrap_err(); assert_eq!( @@ -709,10 +731,34 @@ fn update_gauge() { ); let err = suite - .update_gauge(&owner, gauge_contract, 0, new_epoch, new_min_percent, 0) + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + 0, + None, + ) .unwrap_err(); assert_eq!( ContractError::MaxOptionsSelectedTooSmall {}, err.downcast().unwrap() ); + + let err = suite + .update_gauge( + &owner, + gauge_contract, + 1, + None, + Some(Decimal::zero()), + None, + Some(Decimal::percent(101)), + ) + .unwrap_err(); + assert_eq!( + ContractError::MaxAvailablePercentTooBig {}, + err.downcast().unwrap() + ); } diff --git a/contracts/gauge/src/multitest/suite.rs b/contracts/gauge/src/multitest/suite.rs index a1909430c..b24b3a985 100644 --- a/contracts/gauge/src/multitest/suite.rs +++ b/contracts/gauge/src/multitest/suite.rs @@ -467,8 +467,13 @@ impl Suite { gauge_contract: Addr, options: &[&str], to_distribute: (u128, &str), + max_available_percentage: impl Into>, ) -> AnyResult { - let option = self.instantiate_adapter_and_return_config(options, to_distribute)?; + let option = self.instantiate_adapter_and_return_config( + options, + to_distribute, + max_available_percentage, + )?; let gauge_adapter = option.adapter.clone(); self.app.execute_contract( Addr::unchecked(&self.owner), @@ -483,6 +488,7 @@ impl Suite { &mut self, options: &[&str], to_distribute: (u128, &str), + max_available_percentage: impl Into>, ) -> AnyResult { let gauge_adapter = self.app.instantiate_contract( self.gauge_adapter_code_id, @@ -502,9 +508,11 @@ impl Suite { epoch_size: 7 * 86400, min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, + max_available_percentage: max_available_percentage.into(), }) } + #[allow(clippy::too_many_arguments)] pub fn update_gauge( &mut self, sender: &str, @@ -513,6 +521,7 @@ impl Suite { epoch_size: impl Into>, min_percent_selected: Option, max_options_selected: impl Into>, + max_available_percentage: impl Into>, ) -> AnyResult { self.app.execute_contract( Addr::unchecked(sender), @@ -522,6 +531,7 @@ impl Suite { epoch_size: epoch_size.into(), min_percent_selected, max_options_selected: max_options_selected.into(), + max_available_percentage: max_available_percentage.into(), }, &[], ) diff --git a/contracts/gauge/src/multitest/tally.rs b/contracts/gauge/src/multitest/tally.rs index 404a2fd47..a8a920e80 100644 --- a/contracts/gauge/src/multitest/tally.rs +++ b/contracts/gauge/src/multitest/tally.rs @@ -45,6 +45,7 @@ fn multiple_options_one_gauge() { gauge_contract.clone(), &["option1", "option2", "option3", "option4", "option5"], reward_to_distribute, + None, ) .unwrap(); let gauge_id = 0; @@ -145,7 +146,7 @@ fn multiple_options_two_gauges() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&["option1", "option2"], reward_to_distribute) + .instantiate_adapter_and_return_config(&["option1", "option2"], reward_to_distribute, None) .unwrap(); suite .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) @@ -170,6 +171,7 @@ fn multiple_options_two_gauges() { gauge_contract.clone(), &["option3", "option4", "option5"], reward_to_distribute, + None, ) .unwrap(); let second_gauge_id = 1; @@ -271,6 +273,7 @@ fn not_voted_options_are_not_selected() { gauge_contract.clone(), &["option1", "option2", "option3", "option4"], reward_to_distribute, + None, ) .unwrap(); let first_gauge_id = 0; diff --git a/contracts/gauge/src/multitest/voting.rs b/contracts/gauge/src/multitest/voting.rs index 9f2c22ee1..fd62854ec 100644 --- a/contracts/gauge/src/multitest/voting.rs +++ b/contracts/gauge/src/multitest/voting.rs @@ -42,6 +42,7 @@ fn add_option() { gauge_contract.clone(), &[voter1, voter2], (1000, "ujuno"), + None, ) .unwrap(); @@ -139,6 +140,7 @@ fn vote_for_option() { gauge_contract.clone(), &[voter1, voter2], (1000, "ujuno"), + None, ) .unwrap(); @@ -270,6 +272,7 @@ fn remove_vote() { gauge_contract.clone(), &[voter1, voter2], (1000, "ujuno"), + None, ) .unwrap(); @@ -340,7 +343,7 @@ fn votes_stays_the_same_after_execution() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute) + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None) .unwrap(); suite .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) @@ -416,3 +419,95 @@ fn votes_stays_the_same_after_execution() { Some(simple_vote(voter2, voter1, 100)), ); } + +#[test] +fn vote_for_max_capped_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[0].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + Some(Decimal::percent(10)), + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // wait until epoch passes + suite.advance_time(EPOCH); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + + // vote 100% voting power on 'voter1' option (100 weight) + suite + .place_vote( + &gauge_contract, + voter1, + gauge_id, + Some("option1".to_owned()), + ) + .unwrap(); + // vote 10% voting power on 'voter2' option (10 weight) + suite + .place_votes( + &gauge_contract, + voter2, + gauge_id, + vec![("option2".to_owned(), Decimal::percent(10))], + ) + .unwrap(); + + assert_eq!( + vec![ + multi_vote(voter1, &[("option1", 100)]), + multi_vote(voter2, &[("option2", 10)]), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // Despite 'option1' having 100 voting power and option2 having 10 voting power, + // because of max vote cap set to 10% now 'option1' will have its power decreased to 10% * 110 + // 'option2' stays at 10 voting power as it was below 10% of total votes + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(11)), + ("option2".to_owned(), Uint128::new(10)) + ] + ); +} diff --git a/contracts/gauge/src/state.rs b/contracts/gauge/src/state.rs index b804b3196..8ceef6f65 100644 --- a/contracts/gauge/src/state.rs +++ b/contracts/gauge/src/state.rs @@ -54,6 +54,8 @@ pub struct Gauge { /// Maximum number of Options to make the selected set. Needed even with /// `min_percent_selected` to provide some guarantees on gas usage of this query. pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, /// True if the gauge is stopped pub is_stopped: bool, /// UNIX time (seconds) when next epoch can be executed. If < env.block.time then Execute can be called From f75180dfb83e9ffae5c935029cfe58bc59581782 Mon Sep 17 00:00:00 2001 From: Jakub Bogucki Date: Thu, 13 Apr 2023 15:24:57 +0200 Subject: [PATCH 05/12] Set version: 1.7.0 --- contracts/gauge/Cargo.toml | 6 +- contracts/gauge/src/contract.rs | 186 ++++-- contracts/gauge/src/error.rs | 9 + contracts/gauge/src/msg.rs | 34 +- contracts/gauge/src/multitest/adapter.rs | 24 +- contracts/gauge/src/multitest/gauge.rs | 40 +- contracts/gauge/src/multitest/mod.rs | 1 + contracts/gauge/src/multitest/reset.rs | 602 ++++++++++++++++++ contracts/gauge/src/multitest/suite.rs | 91 ++- contracts/gauge/src/multitest/tally.rs | 10 +- contracts/gauge/src/multitest/voting.rs | 208 +++++- contracts/gauge/src/state.rs | 151 ++++- .../marketing-gauge-adapter/.cargo/config | 5 + contracts/marketing-gauge-adapter/Cargo.toml | 34 + contracts/marketing-gauge-adapter/README.md | 28 + .../marketing-gauge-adapter/src/bin/schema.rs | 13 + .../marketing-gauge-adapter/src/contract.rs | 515 +++++++++++++++ .../marketing-gauge-adapter/src/error.rs | 26 + contracts/marketing-gauge-adapter/src/lib.rs | 9 + contracts/marketing-gauge-adapter/src/msg.rs | 99 +++ .../src/multitest/mod.rs | 4 + .../src/multitest/options.rs | 50 ++ .../src/multitest/submission.rs | 484 ++++++++++++++ .../src/multitest/suite.rs | 332 ++++++++++ .../marketing-gauge-adapter/src/state.rs | 55 ++ 25 files changed, 2922 insertions(+), 94 deletions(-) create mode 100644 contracts/gauge/src/multitest/reset.rs create mode 100644 contracts/marketing-gauge-adapter/.cargo/config create mode 100644 contracts/marketing-gauge-adapter/Cargo.toml create mode 100644 contracts/marketing-gauge-adapter/README.md create mode 100644 contracts/marketing-gauge-adapter/src/bin/schema.rs create mode 100644 contracts/marketing-gauge-adapter/src/contract.rs create mode 100644 contracts/marketing-gauge-adapter/src/error.rs create mode 100644 contracts/marketing-gauge-adapter/src/lib.rs create mode 100644 contracts/marketing-gauge-adapter/src/msg.rs create mode 100644 contracts/marketing-gauge-adapter/src/multitest/mod.rs create mode 100644 contracts/marketing-gauge-adapter/src/multitest/options.rs create mode 100644 contracts/marketing-gauge-adapter/src/multitest/submission.rs create mode 100644 contracts/marketing-gauge-adapter/src/multitest/suite.rs create mode 100644 contracts/marketing-gauge-adapter/src/state.rs diff --git a/contracts/gauge/Cargo.toml b/contracts/gauge/Cargo.toml index 7f9324923..82f6aa3c7 100644 --- a/contracts/gauge/Cargo.toml +++ b/contracts/gauge/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "gauge-orchestrator" version = { workspace = true } -authors = [ "Cosmorama " ] +authors = ["Cosmorama "] edition = { workspace = true } [lib] @@ -22,9 +22,6 @@ schemars = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } wynd-stake = { workspace = true } -# Temporarily added for migration tests -semver = { workspace = true } -gauge-orchestrator-1_5_3 = { git = "https://github.com/cosmorama/wynddao.git", tag="v1.5.3", package = "gauge-orchestrator" } [dev-dependencies] anyhow = { workspace = true } @@ -35,3 +32,4 @@ cw4 = { workspace = true } cw4-group = { workspace = true } cw4-voting = { workspace = true } voting = { workspace = true } +gauge-orchestrator-1_6 = { git = "https://github.com/cosmorama/wynddao.git", tag = "v1.6.0", package = "gauge-orchestrator" } diff --git a/contracts/gauge/src/contract.rs b/contracts/gauge/src/contract.rs index 32474db86..e1786c1d5 100644 --- a/contracts/gauge/src/contract.rs +++ b/contracts/gauge/src/contract.rs @@ -13,7 +13,6 @@ use cw_storage_plus::Bound; use cw_utils::ensure_from_older_version; use wynd_stake::hook::MemberDiff; -use crate::error::ContractError; use crate::msg::{ AdapterQueryMsg, AllOptionsResponse, CheckOptionResponse, ExecuteMsg, GaugeConfig, GaugeResponse, InstantiateMsg, ListGaugesResponse, ListOptionsResponse, ListVotesResponse, @@ -23,8 +22,7 @@ use crate::state::{ fetch_last_id, update_tally, votes, Config, Gauge, GaugeId, CONFIG, GAUGES, OPTION_BY_POINTS, TALLY, TOTAL_CAST, }; - -use semver::Version; +use crate::{error::ContractError, state::Reset}; // version info for migration info const CONTRACT_NAME: &str = "crates.io:gauge"; @@ -86,11 +84,17 @@ pub fn execute( max_available_percentage, ), ExecuteMsg::StopGauge { gauge } => execute::stop_gauge(deps, info.sender, gauge), + ExecuteMsg::ResetGauge { gauge, batch_size } => { + execute::reset_gauge(deps, env, gauge, batch_size) + } ExecuteMsg::AddOption { gauge, option } => { execute::add_option(deps, info.sender, gauge, option, true) } + ExecuteMsg::RemoveOption { gauge, option } => { + execute::remove_option(deps, info.sender, gauge, option) + } ExecuteMsg::PlaceVotes { gauge, votes } => { - execute::place_votes(deps, info.sender, gauge, votes) + execute::place_votes(deps, env, info.sender, gauge, votes) } ExecuteMsg::Execute { gauge } => execute::execute(deps, env, gauge), } @@ -98,7 +102,7 @@ pub fn execute( mod execute { use super::*; - use crate::state::{update_tallies, Vote}; + use crate::state::{remove_tally, update_tallies, Reset, Vote}; use std::collections::HashMap; pub fn member_changed( @@ -112,6 +116,7 @@ mod execute { } let mut response = Response::new().add_attribute("action", "member_changed_hook"); + let mut gauges = HashMap::new(); for diff in diffs { response = response.add_attribute("member", &diff.key); @@ -126,6 +131,15 @@ mod execute { let old = diff.old.unwrap_or_default(); let new = diff.new.unwrap_or_default(); + // load gauge if not already loaded + let gauge = gauges + .entry(vote.gauge_id) + .or_insert_with(|| GAUGES.load(deps.storage, vote.gauge_id).unwrap()); + + if vote.is_expired(gauge) { + continue; + } + // calculate updates and adjust tallies let updates: Vec<_> = vote .votes @@ -164,7 +178,7 @@ mod execute { Ok(Response::new() .add_attribute("action", "create_gauge") - .add_attribute("adapter", &adapter)) + .add_attribute("adapter", adapter)) } pub fn attach_gauge( @@ -177,6 +191,7 @@ mod execute { min_percent_selected, max_options_selected, max_available_percentage, + reset_epoch, }: GaugeConfig, ) -> Result { let adapter = deps.api.addr_validate(&adapter)?; @@ -202,6 +217,11 @@ mod execute { is_stopped: false, next_epoch: env.block.time.seconds() + epoch_size, last_executed_set: None, + reset: reset_epoch.map(|r| Reset { + last: None, + reset_each: r, + next: env.block.time.plus_seconds(r).seconds(), + }), }; let last_id: GaugeId = fetch_last_id(deps.storage)?; GAUGES.save(deps.storage, last_id, &gauge)?; @@ -295,6 +315,76 @@ mod execute { .add_attribute("gauge_id", gauge_id.to_string())) } + pub fn remove_option( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + option: String, + ) -> Result { + // check if such option even exists + if !TALLY.has(deps.as_ref().storage, (gauge_id, &option)) { + return Err(ContractError::OptionDoesNotExists { option, gauge_id }); + }; + + // only owner can remove option for now + if sender != CONFIG.load(deps.storage)?.owner { + return Err(ContractError::Unauthorized {}); + } + + remove_tally(deps.storage, gauge_id, &option)?; + + Ok(Response::new() + .add_attribute("action", "remove_option") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()) + .add_attribute("option", option)) + } + + pub fn reset_gauge( + deps: DepsMut, + env: Env, + gauge_id: GaugeId, + batch_size: u32, + ) -> Result { + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; + match gauge.reset { + Some(ref mut reset) if reset.next <= env.block.time.seconds() => { + reset.last = Some(reset.next); + + // remove all options from the gauge + let keys = OPTION_BY_POINTS + .sub_prefix(gauge_id) + .keys(deps.storage, None, None, Order::Ascending) + .take(batch_size as usize) + .collect::>>()?; + for (points, option) in &keys { + OPTION_BY_POINTS.remove(deps.storage, (gauge_id, *points, option)); + OPTION_BY_POINTS.save(deps.storage, (gauge_id, 0, option), &1)?; + TALLY.save(deps.storage, (gauge_id, option), &0)?; + } + + // if this is the last batch, update the reset epoch + if (keys.len() as u32) < batch_size { + // removing total cast only once at the end to save gas + TOTAL_CAST.save(deps.storage, gauge_id, &0)?; + reset.next += reset.reset_each; + } + } + Some(_) => { + return Err(ContractError::ResetEpochNotPassed {}); + } + None => { + return Err(ContractError::Unauthorized {}); + } + } + + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new() + .add_attribute("action", "reset_gauge") + .add_attribute("gauge_id", gauge_id.to_string())) + } + pub fn add_option( deps: DepsMut, sender: Addr, @@ -356,12 +446,18 @@ mod execute { pub fn place_votes( deps: DepsMut, + env: Env, sender: Addr, gauge_id: GaugeId, new_votes: Option>, ) -> Result { - if !GAUGES.has(deps.storage, gauge_id) { - return Err(ContractError::GaugeMissing(gauge_id)); + let gauge = match GAUGES.may_load(deps.storage, gauge_id)? { + Some(gauge) => gauge, + None => return Err(ContractError::GaugeMissing(gauge_id)), + }; + + if gauge.is_resetting() { + return Err(ContractError::GaugeResetting(gauge_id)); } // make sure sums work out @@ -386,7 +482,12 @@ mod execute { return Err(ContractError::NoVotingPower(sender.to_string())); } - let previous_vote = votes().may_load(deps.storage, &sender, gauge_id)?; + let mut previous_vote = votes().may_load(deps.storage, &sender, gauge_id)?; + if let Some(v) = &previous_vote { + if v.is_expired(&gauge) { + previous_vote = None; + } + } if previous_vote.is_none() && new_votes.is_empty() { return Err(ContractError::CannotRemoveNonexistingVote {}); } @@ -436,7 +537,14 @@ mod execute { votes().remove_votes(deps.storage, &sender, gauge_id)?; } else { // store sender's new votes (overwriting old votes) - votes().set_votes(deps.storage, &sender, gauge_id, new_votes, voting_power)?; + votes().set_votes( + deps.storage, + &env, + &sender, + gauge_id, + new_votes, + voting_power, + )?; } let response = Response::new() @@ -452,6 +560,9 @@ mod execute { if gauge.is_stopped { return Err(ContractError::GaugeStopped(gauge_id)); } + if gauge.is_resetting() { + return Err(ContractError::GaugeResetting(gauge_id)); + } let current_epoch = env.block.time.seconds(); if current_epoch < gauge.next_epoch { @@ -561,6 +672,7 @@ mod query { max_available_percentage: gauge.max_available_percentage, is_stopped: gauge.is_stopped, next_epoch: gauge.next_epoch, + reset: gauge.reset, } } @@ -595,11 +707,15 @@ mod query { pub fn vote(deps: Deps, gauge_id: u64, voter: String) -> StdResult { let voter_addr = deps.api.addr_validate(&voter)?; + let gauge = GAUGES.load(deps.storage, gauge_id)?; + let vote = votes() .may_load(deps.storage, &voter_addr, gauge_id)? + .filter(|v| !v.is_expired(&gauge)) .map(|v| VoteInfo { voter, votes: v.votes, + cast: v.cast, }); Ok(VoteResponse { vote }) } @@ -641,6 +757,10 @@ mod query { let gauge = GAUGES.load(deps.storage, gauge_id)?; let total_cast = TOTAL_CAST.load(deps.storage, gauge_id)?; + if gauge.is_resetting() || total_cast == 0 { + return Ok(SelectedSetResponse { votes: vec![] }); + } + // This is sorted index, but requires manual filtering - cannot be prefixed // given our requirements let votes = OPTION_BY_POINTS @@ -683,42 +803,36 @@ mod query { #[cfg_attr(not(feature = "library"), entry_point)] pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg) -> Result { - let version = ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - for (gauge_id, next_epoch) in msg.next_epochs.unwrap_or_default() { + for (gauge_id, config) in msg.gauge_config.unwrap_or_default() { GAUGES.update(deps.storage, gauge_id, |gauge| -> StdResult<_> { let mut gauge = gauge.ok_or(StdError::NotFound { kind: format!("Gauge with id {}", gauge_id), })?; - if next_epoch < env.block.time.seconds() { - return Err(StdError::GenericErr { - msg: "Next epoch value cannot be earlier then current epoch!".to_owned(), + if let Some(next_epoch) = config.next_epoch { + if next_epoch < env.block.time.seconds() { + return Err(StdError::GenericErr { + msg: "Next epoch value cannot be earlier then current epoch!".to_owned(), + }); + } + gauge.next_epoch = next_epoch; + } + if let Some(reset_config) = config.reset { + if reset_config.next_reset < env.block.time.seconds() { + return Err(StdError::GenericErr { + msg: "Next reset value cannot be earlier then current epoch!".to_owned(), + }); + } + gauge.reset = Some(Reset { + last: gauge.reset.map(|r| r.last).unwrap_or_default(), + reset_each: reset_config.reset_epoch, + next: reset_config.next_reset, }); } - gauge.next_epoch = next_epoch; Ok(gauge) })?; } - // temporary implementation - since wyndex has only one gauge right now, update it quickly - if version <= "1.5.3".parse::().unwrap() { - use cw_storage_plus::Map; - let old_storage: Map = Map::new("gauges"); - let gauge = old_storage.load(deps.storage, 0)?; - let new_gauge = Gauge { - title: gauge.title, - adapter: gauge.adapter, - epoch: gauge.epoch, - min_percent_selected: gauge.min_percent_selected, - max_options_selected: gauge.max_options_selected, - max_available_percentage: None, - is_stopped: gauge.is_stopped, - next_epoch: gauge.next_epoch, - // this is fine, it will be overwritten on next gauge execution - last_executed_set: None, - }; - GAUGES.save(deps.storage, 0, &new_gauge)?; - } - Ok(Response::new()) } diff --git a/contracts/gauge/src/error.rs b/contracts/gauge/src/error.rs index 1ad1edea4..c320a4ec7 100644 --- a/contracts/gauge/src/error.rs +++ b/contracts/gauge/src/error.rs @@ -24,6 +24,9 @@ pub enum ContractError { #[error("Option {option} has been judged as invalid by gauge adapter of gauge ID {gauge_id}")] OptionInvalidByAdapter { option: String, gauge_id: u64 }, + #[error("Option {option} has been judged as valid by gauge adapter of gauge ID {gauge_id} and cannot be removed")] + OptionValidByAdapter { option: String, gauge_id: u64 }, + #[error("Option {option} does not exists for gauge ID {gauge_id}")] OptionDoesNotExists { option: String, gauge_id: u64 }, @@ -34,9 +37,15 @@ pub enum ContractError { next_epoch: u64, }, + #[error("Reset epoch has not passed yet")] + ResetEpochNotPassed {}, + #[error("Gauge ID {0} cannot execute because it is stopped")] GaugeStopped(u64), + #[error("Gauge ID {0} is currently resetting, please try again later")] + GaugeResetting(u64), + #[error("Trying to remove vote that does not exists")] CannotRemoveNonexistingVote {}, diff --git a/contracts/gauge/src/msg.rs b/contracts/gauge/src/msg.rs index 1f4ccde64..293444be0 100644 --- a/contracts/gauge/src/msg.rs +++ b/contracts/gauge/src/msg.rs @@ -1,7 +1,7 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::{CosmosMsg, Decimal, Uint128}; -use crate::state::Vote; +use crate::state::{Reset, Vote}; use wynd_stake::hook::MemberChangedHookMsg; type GaugeId = u64; @@ -34,6 +34,8 @@ pub struct GaugeConfig { pub max_options_selected: u32, // Any votes above that percentage will be discarded pub max_available_percentage: Option, + /// If set, the gauge can be reset periodically, every `reset_epoch` seconds. + pub reset_epoch: Option, } #[cw_serde] @@ -58,12 +60,18 @@ pub enum ExecuteMsg { /// Or receive any more updates on MemberChangedHook. /// Ideally, this will allow for eventual deletion of all data on that gauge StopGauge { gauge: u64 }, + /// Resets all votes on a given gauge if it is configured to be periodically reset and the epoch has passed. + /// One call to this will only clear `batch_size` votes to prevent gas exhaustion. Call repeatedly to clear all votes. + ResetGauge { gauge: u64, batch_size: u32 }, // WISH: make this implicit - call it inside PlaceVote. // If not, I would just make it invisible to user in UI (smart client adds it if needed) /// Try to add an option. Error if no such gauge, or option already registered. /// Otherwise check adapter and error if invalid. /// Can be called by anyone, not just owner AddOption { gauge: u64, option: String }, + /// Allows the owner to remove an option. This is useful if the option is no longer valid + /// or if the owner wants to remove all votes from a valid option. + RemoveOption { gauge: u64, option: String }, /// Place your vote on the gauge. Can be updated anytime PlaceVotes { /// Gauge to vote on @@ -137,6 +145,8 @@ pub struct GaugeResponse { pub is_stopped: bool, /// UNIX time (seconds) when next epoch may be executed. May be future or past pub next_epoch: u64, + /// Set this in migration if the gauge should be periodically reset + pub reset: Option, } /// Information about one gauge @@ -152,6 +162,9 @@ pub struct VoteInfo { pub voter: String, /// List of all votes with power pub votes: Vec, + /// Timestamp when vote was cast. + /// Allow `None` for 0-cost migration from current data + pub cast: Option, } /// Information about a vote. @@ -223,5 +236,22 @@ pub struct SampleGaugeMsgsResponse { #[cw_serde] pub struct MigrateMsg { - pub next_epochs: Option>, + pub gauge_config: Option>, +} + +#[cw_serde] +#[derive(Default)] +pub struct GaugeMigrationConfig { + /// When the next epoch should be executed + pub next_epoch: Option, + /// If set, the gauge will be reset periodically + pub reset: Option, +} + +#[cw_serde] +pub struct ResetMigrationConfig { + /// How often to reset the gauge (in seconds) + pub reset_epoch: u64, + /// When to start the first reset + pub next_reset: u64, } diff --git a/contracts/gauge/src/multitest/adapter.rs b/contracts/gauge/src/multitest/adapter.rs index c15069880..d7101d93b 100644 --- a/contracts/gauge/src/multitest/adapter.rs +++ b/contracts/gauge/src/multitest/adapter.rs @@ -21,6 +21,12 @@ pub struct InstantiateMsg { pub to_distribute: Coin, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ExecuteMsg { + InvalidateOption { option: String }, + AddValidOption { option: String }, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct EmptyMsg {} @@ -41,11 +47,19 @@ fn instantiate( } fn execute( - _deps: DepsMut, + deps: DepsMut, _env: Env, _info: MessageInfo, - _msg: EmptyMsg, + msg: ExecuteMsg, ) -> Result { + match msg { + ExecuteMsg::InvalidateOption { option } => { + OPTIONS.remove(deps.storage, option); + } + ExecuteMsg::AddValidOption { option } => { + OPTIONS.save(deps.storage, option, &true)?; + } + } Ok(Response::new()) } @@ -56,9 +70,9 @@ fn query(deps: Deps, _env: Env, msg: AdapterQueryMsg) -> Result>>()?, }), - AdapterQueryMsg::CheckOption { option: _ } => { - to_binary(&CheckOptionResponse { valid: true }) - } + AdapterQueryMsg::CheckOption { option } => to_binary(&CheckOptionResponse { + valid: OPTIONS.has(deps.storage, option), + }), AdapterQueryMsg::SampleGaugeMsgs { selected } => { let to_distribute = TO_DISTRIBUTE.load(deps.storage)?; let mut weights_sum = Decimal::zero(); diff --git a/contracts/gauge/src/multitest/gauge.rs b/contracts/gauge/src/multitest/gauge.rs index 1a9d17855..fc14dc267 100644 --- a/contracts/gauge/src/multitest/gauge.rs +++ b/contracts/gauge/src/multitest/gauge.rs @@ -4,7 +4,7 @@ use voting::Vote; use super::suite::{Suite, SuiteBuilder}; use crate::error::ContractError; -use crate::msg::GaugeResponse; +use crate::msg::{GaugeMigrationConfig, GaugeResponse}; const EPOCH: u64 = 7 * 86_400; @@ -24,6 +24,7 @@ fn create_gauge() { &[voter1, voter2], (1000, "ujuno"), None, + None, ) .unwrap(); @@ -40,6 +41,7 @@ fn create_gauge() { max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, } ); } @@ -59,6 +61,7 @@ fn gauge_can_upgrade_from_self() { &["option1", "option2"], (1000, "ujuno"), None, + None, ) .unwrap(); @@ -78,6 +81,7 @@ fn gauge_can_upgrade_from_self() { max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, } ); } @@ -97,6 +101,7 @@ fn gauge_migrate_with_next_epochs() { &["option1", "option2"], (1000, "ujuno"), None, + None, ) .unwrap(); @@ -114,6 +119,7 @@ fn gauge_migrate_with_next_epochs() { max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, } ); @@ -123,7 +129,13 @@ fn gauge_migrate_with_next_epochs() { suite .auto_migrate_gauge( &gauge_contract, - vec![(gauge_id, suite.current_time() + 14 * 86400)], + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: Some(suite.current_time() + 14 * 86400), + reset: None, + }, + )], ) .unwrap(); @@ -140,6 +152,7 @@ fn gauge_migrate_with_next_epochs() { max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 14 * 86400, + reset: None, } ); @@ -149,7 +162,13 @@ fn gauge_migrate_with_next_epochs() { let _err = suite .auto_migrate_gauge( &gauge_contract, - vec![(420, suite.current_time() + 14 * 86400)], + vec![( + 420, + GaugeMigrationConfig { + next_epoch: Some(suite.current_time() + 14 * 86400), + reset: None, + }, + )], ) .unwrap_err(); } @@ -167,7 +186,7 @@ fn execute_gauge() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None) + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) .unwrap(); suite .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) @@ -269,6 +288,7 @@ fn query_last_execution() { &[voter1, voter2, gauge_contract.as_str()], (1000, "ujuno"), None, + None, ) .unwrap(); let gauge_id = 0; @@ -361,7 +381,7 @@ fn execute_gauge_twice_same_epoch() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&[voter1, voter2], (1000, "ujuno"), None) // reward per + .instantiate_adapter_and_return_config(&[voter1, voter2], (1000, "ujuno"), None, None) // reward per // epoch .unwrap(); suite @@ -472,7 +492,7 @@ fn execute_stopped_gauge() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None) + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) .unwrap(); suite .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) @@ -557,6 +577,7 @@ fn update_gauge() { &[voter1, voter2], (1000, "ujuno"), None, + None, ) .unwrap(); @@ -566,6 +587,7 @@ fn update_gauge() { &[voter1, voter2], (1000, "uusdc"), None, + None, ) .unwrap(); @@ -583,6 +605,7 @@ fn update_gauge() { max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, }, GaugeResponse { id: 1, @@ -594,6 +617,7 @@ fn update_gauge() { max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, } ] ); @@ -630,6 +654,7 @@ fn update_gauge() { max_available_percentage: new_max_available_percentage, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, }, GaugeResponse { id: 1, @@ -641,6 +666,7 @@ fn update_gauge() { max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, } ] ); @@ -672,6 +698,7 @@ fn update_gauge() { max_available_percentage: new_max_available_percentage, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, }, GaugeResponse { id: 1, @@ -683,6 +710,7 @@ fn update_gauge() { max_available_percentage: None, is_stopped: false, next_epoch: suite.current_time() + 7 * 86400, + reset: None, } ] ); diff --git a/contracts/gauge/src/multitest/mod.rs b/contracts/gauge/src/multitest/mod.rs index b66a57246..7ecb09742 100644 --- a/contracts/gauge/src/multitest/mod.rs +++ b/contracts/gauge/src/multitest/mod.rs @@ -1,5 +1,6 @@ mod adapter; mod gauge; +mod reset; mod suite; mod tally; mod voting; diff --git a/contracts/gauge/src/multitest/reset.rs b/contracts/gauge/src/multitest/reset.rs new file mode 100644 index 000000000..41521a6fc --- /dev/null +++ b/contracts/gauge/src/multitest/reset.rs @@ -0,0 +1,602 @@ +use cosmwasm_std::{Addr, Decimal, StdError, Uint128}; +use cw_multi_test::{App, ContractWrapper, Executor}; +use voting::Vote; + +use crate::{ + msg::{GaugeMigrationConfig, GaugeResponse, ResetMigrationConfig, VoteInfo}, + multitest::suite::SuiteBuilder, + ContractError, +}; + +const EPOCH: u64 = 7 * 86_400; +const RESET_EPOCH: u64 = 30 * 86_400; + +#[test] +fn basic_gauge_reset() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config( + &[voter1, voter2], + reward_to_distribute, + None, + RESET_EPOCH, + ) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // cannot reset before epoch has passed + assert_eq!( + ContractError::ResetEpochNotPassed {}, + suite + .reset_gauge("anyone", &gauge_contract, gauge_id, 10) + .unwrap_err() + .downcast() + .unwrap() + ); + + // reset + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 100) // 100 is way more than needed + .unwrap(); + + // check that gauge was reset + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![]); + let votes = suite.query_list_votes(&gauge_contract, gauge_id).unwrap(); + assert_eq!(votes, vec![]); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + None, + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + None, + ); + // options should still be there + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // actually execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + assert_eq!( + suite + .query_balance(suite.core.as_str(), reward_to_distribute.1) + .unwrap(), + reward_to_distribute.0, + "nothing should be distributed yet, since all votes were reset" + ); + + // vote again + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter2.to_owned()), + ) + .unwrap(); + + // check that vote counts + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![("voter2".to_owned(), Uint128::new(100))]); + + // another epoch is fine + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter2, reward_to_distribute.1).unwrap(), + 2000u128 + ); +} + +fn store_old_gauge(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + gauge_orchestrator_1_6::contract::execute, + gauge_orchestrator_1_6::contract::instantiate, + gauge_orchestrator_1_6::contract::query, + ) + .with_migrate(gauge_orchestrator_1_6::contract::migrate), + ); + + app.store_code(contract) +} + +#[test] +fn gauge_migrate_with_reset() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + // setup gauge + suite.next_block(); + suite.propose_update_proposal_module(voter1, None).unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite.next_block(); + suite.execute_single_proposal(voter1, proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + assert_eq!(proposal_modules.len(), 2); + let gauge_contract = proposal_modules[0].clone(); + + // create adapter + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + // previous settings + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); + + // now let's migrate the gauge and make sure nothing breaks + let gauge_id = 0; + // try to migrate to past reset should fail + assert_eq!( + ContractError::from(StdError::generic_err( + "Next reset value cannot be earlier then current epoch!" + )), + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() - 1, + }), + }, + )], + ) + .unwrap_err() + .downcast() + .unwrap() + ); + + // migrate to reset epoch + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() + 100, + }), + }, + )], + ) + .unwrap(); + + // check that gauge was migrated + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: Some(crate::state::Reset { + last: None, + reset_each: RESET_EPOCH, + next: suite.current_time() + 100, + }), + } + ); +} + +#[test] +fn gauge_migrate_keeps_last_reset() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + // setup gauge + suite.next_block(); + suite.propose_update_proposal_module(voter1, None).unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite.next_block(); + suite.execute_single_proposal(voter1, proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + assert_eq!(proposal_modules.len(), 2); + let gauge_contract = proposal_modules[0].clone(); + + // create adapter + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + Some(RESET_EPOCH), + ) + .unwrap(); + let gauge_id = 0; + + // reset gauge once before migration + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); + let gauge = suite.query_gauge(gauge_contract.clone(), gauge_id).unwrap(); + assert_eq!(gauge.reset.unwrap().last, Some(suite.current_time())); + + // now let's migrate the gauge and make sure nothing breaks + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() + 100, + }), + }, + )], + ) + .unwrap(); + + // check that last reset is still the same + let gauge = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!(gauge.reset.unwrap().last, Some(suite.current_time())); +} + +#[test] +fn partial_reset() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config( + &[voter1, voter2], + reward_to_distribute, + None, + RESET_EPOCH, + ) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[0].clone(); + + let gauge_id = 0; + + // vote for the gauge options + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter2.to_owned()), + ) + .unwrap(); + + // start resetting + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); + + // try to vote during reset + assert_eq!( + ContractError::GaugeResetting(gauge_id), + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter2.to_owned())) + .unwrap_err() + .downcast() + .unwrap() + ); + // check selected set query + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![]); + // check votes list + let votes = suite.query_list_votes(&gauge_contract, gauge_id).unwrap(); + assert_eq!(votes, vec![]); + + // finish resetting + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); +} + +#[test] +fn vote_migration() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + // setup old gauge version + let old_gauge = store_old_gauge(&mut suite.app); + let new_gauge = suite.gauge_code_id; + suite.gauge_code_id = old_gauge; + suite.next_block(); + suite.propose_update_proposal_module(voter1, None).unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + suite.next_block(); + suite.execute_single_proposal(voter1, proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + assert_eq!(proposal_modules.len(), 2); + let gauge_contract = proposal_modules[0].clone(); + let gauge_id = 0; + + // create adapter + let option = suite + .instantiate_adapter_and_return_config(&["option1", "option2"], (1000, "ujuno"), None, None) + .unwrap(); + suite + .app + .execute_contract( + Addr::unchecked(&suite.owner), + gauge_contract.clone(), + &gauge_orchestrator_1_6::msg::ExecuteMsg::CreateGauge( + gauge_orchestrator_1_6::msg::GaugeConfig { + title: option.title, + adapter: option.adapter, + epoch_size: option.epoch_size, + min_percent_selected: option.min_percent_selected, + max_options_selected: option.max_options_selected, + max_available_percentage: option.max_available_percentage, + }, + ), + &[], + ) + .unwrap(); + + // place votes + suite + .place_vote( + &gauge_contract, + voter1, + gauge_id, + Some("option1".to_owned()), + ) + .unwrap(); + suite + .place_votes( + &gauge_contract, + voter2, + gauge_id, + vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ], + ) + .unwrap(); + + // migrate the gauge to new code + suite.gauge_code_id = new_gauge; + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() + 100, + }), + }, + )], + ) + .unwrap(); + + // check that all votes are still there + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(200)), + ("option2".to_owned(), Uint128::new(100)) + ] + ); + let votes = suite.query_list_votes(&gauge_contract, gauge_id).unwrap(); + let expected_votes1 = VoteInfo { + voter: voter1.to_owned(), + votes: vec![crate::state::Vote { + option: "option1".to_owned(), + weight: Decimal::one(), + }], + cast: None, + }; + let expected_votes2 = VoteInfo { + voter: voter2.to_owned(), + votes: vec![ + crate::state::Vote { + option: "option1".to_owned(), + weight: Decimal::percent(50), + }, + crate::state::Vote { + option: "option2".to_owned(), + weight: Decimal::percent(50), + }, + ], + cast: None, + }; + assert_eq!( + votes, + vec![expected_votes1.clone(), expected_votes2.clone()] + ); + let vote1 = suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(); + assert_eq!(vote1, Some(expected_votes1)); + let vote2 = suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(); + assert_eq!(vote2, Some(expected_votes2)); + + // change vote + suite + .place_vote( + &gauge_contract, + voter1, + gauge_id, + Some("option2".to_owned()), + ) + .unwrap(); + let vote1 = suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(); + assert_eq!( + vote1, + Some(VoteInfo { + voter: voter1.to_owned(), + votes: vec![crate::state::Vote { + option: "option2".to_owned(), + weight: Decimal::one() + }], + cast: Some(suite.current_time()), + }) + ); + + // reset the gauge + suite.advance_time(100); // only 100 seconds, because we set it in the migration + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 10) + .unwrap(); + + // check that all votes are gone + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![]); + let votes = suite.query_list_votes(&gauge_contract, gauge_id).unwrap(); + assert_eq!(votes, vec![]); + let vote1 = suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(); + assert_eq!(vote1, None); + let vote2 = suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(); + assert_eq!(vote2, None); +} diff --git a/contracts/gauge/src/multitest/suite.rs b/contracts/gauge/src/multitest/suite.rs index b24b3a985..a6739e71b 100644 --- a/contracts/gauge/src/multitest/suite.rs +++ b/contracts/gauge/src/multitest/suite.rs @@ -16,11 +16,14 @@ use cw_proposal_single::{ use cw_utils::Duration; use voting::{PercentageThreshold, Threshold, Vote}; -use super::adapter::{contract as adapter_contract, InstantiateMsg as AdapterInstantiateMsg}; +use super::adapter::{ + contract as adapter_contract, ExecuteMsg as AdapterExecuteMsg, + InstantiateMsg as AdapterInstantiateMsg, +}; use crate::msg::{ - ExecuteMsg, GaugeConfig, GaugeResponse, InstantiateMsg, LastExecutedSetResponse, - ListGaugesResponse, ListOptionsResponse, ListVotesResponse, MigrateMsg, QueryMsg, - SelectedSetResponse, VoteInfo, VoteResponse, + ExecuteMsg, GaugeConfig, GaugeMigrationConfig, GaugeResponse, InstantiateMsg, + LastExecutedSetResponse, ListGaugesResponse, ListOptionsResponse, ListVotesResponse, + MigrateMsg, QueryMsg, SelectedSetResponse, VoteInfo, VoteResponse, }; type GaugeId = u64; @@ -220,11 +223,11 @@ impl SuiteBuilder { pub struct Suite { pub owner: String, - app: App, - core: Addr, + pub app: App, + pub core: Addr, voting: Addr, proposal_single: Addr, - gauge_code_id: u64, + pub gauge_code_id: u64, gauge_adapter_code_id: u64, } @@ -283,6 +286,56 @@ impl Suite { ) } + pub fn remove_option( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::RemoveOption { + gauge: gauge_id, + option: option.into(), + }, + &[], + ) + } + + /// Helper to remove an option from the test gauge adapter + pub fn invalidate_option( + &mut self, + gauge_adapter: &Addr, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(self.owner.clone()), + gauge_adapter.clone(), + &AdapterExecuteMsg::InvalidateOption { + option: option.into(), + }, + &[], + ) + } + + /// Helper to add an option to the test gauge adapter + pub fn add_valid_option( + &mut self, + gauge_adapter: &Addr, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(self.owner.clone()), + gauge_adapter.clone(), + &AdapterExecuteMsg::AddValidOption { + option: option.into(), + }, + &[], + ) + } + /// Helper to vote for a single option pub fn place_vote( &mut self, @@ -468,11 +521,13 @@ impl Suite { options: &[&str], to_distribute: (u128, &str), max_available_percentage: impl Into>, + reset_epoch: impl Into>, ) -> AnyResult { let option = self.instantiate_adapter_and_return_config( options, to_distribute, max_available_percentage, + reset_epoch, )?; let gauge_adapter = option.adapter.clone(); self.app.execute_contract( @@ -489,6 +544,7 @@ impl Suite { options: &[&str], to_distribute: (u128, &str), max_available_percentage: impl Into>, + reset_epoch: impl Into>, ) -> AnyResult { let gauge_adapter = self.app.instantiate_contract( self.gauge_adapter_code_id, @@ -509,6 +565,7 @@ impl Suite { min_percent_selected: Some(Decimal::percent(5)), max_options_selected: 10, max_available_percentage: max_available_percentage.into(), + reset_epoch: reset_epoch.into(), }) } @@ -537,6 +594,21 @@ impl Suite { ) } + pub fn reset_gauge( + &mut self, + sender: &str, + gauge_contract: &Addr, + gauge: u64, + batch_size: u32, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge_contract.clone(), + &ExecuteMsg::ResetGauge { gauge, batch_size }, + &[], + ) + } + pub fn place_vote_single( &mut self, voter: impl Into, @@ -593,14 +665,15 @@ impl Suite { pub fn auto_migrate_gauge( &mut self, gauge: &Addr, - next_epochs: impl Into>>, + gauge_config: impl Into>>, ) -> AnyResult { let sender = Addr::unchecked(&self.owner); + self.app.migrate_contract( sender, gauge.clone(), &MigrateMsg { - next_epochs: next_epochs.into(), + gauge_config: gauge_config.into(), }, self.gauge_code_id, ) diff --git a/contracts/gauge/src/multitest/tally.rs b/contracts/gauge/src/multitest/tally.rs index a8a920e80..9f9c040d9 100644 --- a/contracts/gauge/src/multitest/tally.rs +++ b/contracts/gauge/src/multitest/tally.rs @@ -46,6 +46,7 @@ fn multiple_options_one_gauge() { &["option1", "option2", "option3", "option4", "option5"], reward_to_distribute, None, + None, ) .unwrap(); let gauge_id = 0; @@ -146,7 +147,12 @@ fn multiple_options_two_gauges() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&["option1", "option2"], reward_to_distribute, None) + .instantiate_adapter_and_return_config( + &["option1", "option2"], + reward_to_distribute, + None, + None, + ) .unwrap(); suite .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) @@ -172,6 +178,7 @@ fn multiple_options_two_gauges() { &["option3", "option4", "option5"], reward_to_distribute, None, + None, ) .unwrap(); let second_gauge_id = 1; @@ -274,6 +281,7 @@ fn not_voted_options_are_not_selected() { &["option1", "option2", "option3", "option4"], reward_to_distribute, None, + None, ) .unwrap(); let first_gauge_id = 0; diff --git a/contracts/gauge/src/multitest/voting.rs b/contracts/gauge/src/multitest/voting.rs index fd62854ec..773035d16 100644 --- a/contracts/gauge/src/multitest/voting.rs +++ b/contracts/gauge/src/multitest/voting.rs @@ -37,12 +37,13 @@ fn add_option() { let gauge_contract = proposal_modules[0].clone(); - suite + let gauge_adapter = suite .instantiate_adapter_and_create_gauge( gauge_contract.clone(), &[voter1, voter2], (1000, "ujuno"), None, + None, ) .unwrap(); @@ -52,6 +53,14 @@ fn add_option() { let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); assert_eq!(options.len(), 2); + // add more valid options to gauge adapter + suite + .add_valid_option(&gauge_adapter, "addedoption1") + .unwrap(); + suite + .add_valid_option(&gauge_adapter, "addedoption2") + .unwrap(); + // Voting members can add options suite .add_option(&gauge_contract, voter1, gauge_id, "addedoption1") @@ -71,6 +80,10 @@ fn add_option() { ] ); + // add another valid option to gauge adapter + suite + .add_valid_option(&gauge_adapter, "addedoption3") + .unwrap(); // Non-voting members cannot add options let err = suite .add_option(&gauge_contract, "random_voter", gauge_id, "addedoption3") @@ -81,17 +94,140 @@ fn add_option() { ); } -fn simple_vote(voter: &str, option: &str, percentage: u64) -> VoteInfo { +#[test] +fn remove_option() { + let owner = "owner"; + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[0].clone(); + + let adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // gauge returns list all options; it does query adapter at initialization + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // add new valid options to the gauge adapter + suite.add_valid_option(&adapter, "addedoption1").unwrap(); + suite.add_valid_option(&adapter, "addedoption2").unwrap(); + + // Voting members can add options + suite + .add_option(&gauge_contract, voter1, gauge_id, "addedoption1") + .unwrap(); + suite + .add_option(&gauge_contract, voter2, gauge_id, "addedoption2") + .unwrap(); + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // added options are automatically voted for by creators + assert_eq!( + options, + vec![ + ("addedoption1".to_owned(), Uint128::zero()), + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // owner can remove an option that has been added already + suite + .remove_option(&gauge_contract, owner, gauge_id, "addedoption1") + .unwrap(); + + // Anyone else cannot remove options + let err = suite + .remove_option(&gauge_contract, voter1, gauge_id, "addedoption2") + .unwrap_err(); + + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // one has been removed + assert_eq!( + options, + vec![ + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + suite.invalidate_option(&adapter, "addedoption2").unwrap(); + + // owner can remove an option that is no longer valid + suite + .remove_option(&gauge_contract, owner, gauge_id, "addedoption2") + .unwrap(); + + // Both options are now removed + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); +} + +fn simple_vote( + voter: &str, + option: &str, + percentage: u64, + cast: impl Into>, +) -> VoteInfo { VoteInfo { voter: voter.to_string(), votes: vec![crate::state::Vote { option: option.to_string(), weight: Decimal::percent(percentage), }], + cast: cast.into(), } } -fn multi_vote(voter: &str, votes: &[(&str, u64)]) -> VoteInfo { +fn multi_vote(voter: &str, votes: &[(&str, u64)], cast: impl Into>) -> VoteInfo { let votes = votes .iter() .map(|(opt, percentage)| crate::state::Vote { @@ -102,6 +238,7 @@ fn multi_vote(voter: &str, votes: &[(&str, u64)]) -> VoteInfo { VoteInfo { voter: voter.to_string(), votes, + cast: cast.into(), } } @@ -135,12 +272,13 @@ fn vote_for_option() { let gauge_contract = proposal_modules[0].clone(); - suite + let gauge_adapter = suite .instantiate_adapter_and_create_gauge( gauge_contract.clone(), &[voter1, voter2], (1000, "ujuno"), None, + None, ) .unwrap(); @@ -157,7 +295,7 @@ fn vote_for_option() { ) .unwrap(); assert_eq!( - simple_vote(voter1, voter1, 90), + simple_vote(voter1, voter1, 90, suite.current_time()), suite .query_vote(&gauge_contract, gauge_id, voter1) .unwrap() @@ -167,6 +305,10 @@ fn vote_for_option() { let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::new(90))]); + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + // change vote for option added through gauge suite .add_option(&gauge_contract, voter1, gauge_id, "option1") @@ -188,8 +330,12 @@ fn vote_for_option() { .unwrap(); assert_eq!( vec![ - simple_vote(voter1, voter1, 90), - multi_vote(voter2, &[("option1", 50), ("option2", 50)]), + simple_vote(voter1, voter1, 90, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), ], suite.query_list_votes(&gauge_contract, gauge_id).unwrap() ); @@ -213,8 +359,8 @@ fn vote_for_option() { .unwrap(); assert_eq!( vec![ - simple_vote(voter1, "option1", 90), - simple_vote(voter2, "option1", 90), + simple_vote(voter1, "option1", 90, suite.current_time()), + simple_vote(voter2, "option1", 90, suite.current_time()), ], suite.query_list_votes(&gauge_contract, gauge_id).unwrap() ); @@ -273,6 +419,7 @@ fn remove_vote() { &[voter1, voter2], (1000, "ujuno"), None, + None, ) .unwrap(); @@ -298,8 +445,8 @@ fn remove_vote() { .unwrap(); assert_eq!( vec![ - simple_vote(voter1, voter1, 100), - simple_vote(voter2, voter1, 100), + simple_vote(voter1, voter1, 100, suite.current_time()), + simple_vote(voter2, voter1, 100, suite.current_time()), ], suite.query_list_votes(&gauge_contract, gauge_id).unwrap() ); @@ -309,7 +456,7 @@ fn remove_vote() { .place_vote(&gauge_contract, voter1.to_owned(), gauge_id, None) .unwrap(); assert_eq!( - vec![simple_vote(voter2, voter1, 100)], + vec![simple_vote(voter2, voter1, 100, suite.current_time())], suite.query_list_votes(&gauge_contract, gauge_id).unwrap() ); assert_eq!( @@ -318,7 +465,7 @@ fn remove_vote() { ); assert_eq!( suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), - Some(simple_vote(voter2, voter1, 100)), + Some(simple_vote(voter2, voter1, 100, suite.current_time())), ); // remove nonexisting vote @@ -343,7 +490,7 @@ fn votes_stays_the_same_after_execution() { suite.next_block(); let gauge_config = suite - .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None) + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) .unwrap(); suite .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) @@ -394,8 +541,8 @@ fn votes_stays_the_same_after_execution() { assert_eq!( vec![ - simple_vote(voter1, voter1, 100), - simple_vote(voter2, voter1, 100) + simple_vote(voter1, voter1, 100, suite.current_time() - EPOCH), + simple_vote(voter2, voter1, 100, suite.current_time() - EPOCH) ], suite.query_list_votes(&gauge_contract, gauge_id).unwrap() ); @@ -405,18 +552,28 @@ fn votes_stays_the_same_after_execution() { assert_eq!( vec![ - simple_vote(voter1, voter1, 100), - simple_vote(voter2, voter1, 100) + simple_vote(voter1, voter1, 100, suite.current_time() - EPOCH), + simple_vote(voter2, voter1, 100, suite.current_time() - EPOCH) ], suite.query_list_votes(&gauge_contract, gauge_id).unwrap() ); assert_eq!( suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), - Some(simple_vote(voter1, voter1, 100)), + Some(simple_vote( + voter1, + voter1, + 100, + suite.current_time() - EPOCH + )), ); assert_eq!( suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), - Some(simple_vote(voter2, voter1, 100)), + Some(simple_vote( + voter2, + voter1, + 100, + suite.current_time() - EPOCH + )), ); } @@ -450,12 +607,13 @@ fn vote_for_max_capped_option() { let gauge_contract = proposal_modules[0].clone(); - suite + let gauge_adapter = suite .instantiate_adapter_and_create_gauge( gauge_contract.clone(), &[voter1, voter2], (1000, "ujuno"), Some(Decimal::percent(10)), + None, ) .unwrap(); @@ -464,6 +622,10 @@ fn vote_for_max_capped_option() { // wait until epoch passes suite.advance_time(EPOCH); + // add more valid options to gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + // change vote for option added through gauge suite .add_option(&gauge_contract, voter1, gauge_id, "option1") @@ -493,8 +655,8 @@ fn vote_for_max_capped_option() { assert_eq!( vec![ - multi_vote(voter1, &[("option1", 100)]), - multi_vote(voter2, &[("option2", 10)]), + multi_vote(voter1, &[("option1", 100)], suite.current_time()), + multi_vote(voter2, &[("option2", 10)], suite.current_time()), ], suite.query_list_votes(&gauge_contract, gauge_id).unwrap() ); diff --git a/contracts/gauge/src/state.rs b/contracts/gauge/src/state.rs index 8ceef6f65..30ad5a974 100644 --- a/contracts/gauge/src/state.rs +++ b/contracts/gauge/src/state.rs @@ -1,5 +1,5 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Addr, Decimal, Deps, Order, StdResult, Storage, Uint128}; +use cosmwasm_std::{Addr, Decimal, Deps, Env, Order, StdResult, Storage, Uint128}; use cw_storage_plus::{Bound, Index, IndexList, IndexedMap, Item, Map, MultiIndex}; use cw_utils::maybe_addr; @@ -62,6 +62,28 @@ pub struct Gauge { pub next_epoch: u64, /// The last set of options selected by the gauge, `None` before the first execution pub last_executed_set: Option>, + /// Set this in migration if the gauge should be periodically reset + pub reset: Option, +} + +#[cw_serde] +pub struct Reset { + /// until the first reset, this is None - needed for 0-cost migration from current state + pub last: Option, + /// seconds between reset + pub reset_each: u64, + /// next time we can reset + pub next: u64, +} + +impl Gauge { + /// Returns `true` if the gauge is currently being reset + pub fn is_resetting(&self) -> bool { + self.reset + .as_ref() + .map(|r| r.last == Some(r.next)) + .unwrap_or_default() + } } #[cw_serde] @@ -72,6 +94,28 @@ pub struct WeightedVotes { pub power: Uint128, /// the user's votes for this gauge pub votes: Vec, + /// Timestamp when vote was cast. + /// Allow `None` for 0-cost migration from current data + pub cast: Option, +} + +impl WeightedVotes { + /// Returns `true` if the vote is + pub fn is_expired(&self, gauge: &Gauge) -> bool { + // check if the vote is older than the last reset + match &gauge.reset { + Some(Reset { + last: Some(expired), + .. + }) => { + // votes with no timestamp are always considered too old once a reset happened + // (they are legacy votes pre-first reset) + self.cast.unwrap_or_default() < *expired + } + // everything is valid before the first reset (last = `None`) or if the gauge is not resettable + _ => false, + } + } } impl Default for WeightedVotes { @@ -80,6 +124,7 @@ impl Default for WeightedVotes { gauge_id: 0, power: Uint128::zero(), votes: vec![], + cast: None, } } } @@ -130,6 +175,7 @@ impl<'a> Votes<'a> { pub fn set_votes( &self, storage: &mut dyn Storage, + env: &Env, voter: &'a Addr, gauge_id: GaugeId, votes: Vec, @@ -143,6 +189,7 @@ impl<'a> Votes<'a> { gauge_id, power, votes, + cast: Some(env.block.time.seconds()), }, ) } @@ -206,17 +253,24 @@ impl<'a> Votes<'a> { let addr = maybe_addr(deps.api, start_after)?; let start = addr.as_ref().map(|a| Bound::exclusive((a, gauge_id))); + let gauge = GAUGES.load(deps.storage, gauge_id)?; + self.votes .idx .vote .prefix(gauge_id) .range(deps.storage, start, None, Order::Ascending) .take(limit) + .filter(|r| match r { + Ok((_, v)) => !v.is_expired(&gauge), // filter out expired votes + Err(_) => true, // keep the error + }) .map(|r| { let ((voter, _gauge), votes) = r?; Ok(VoteInfo { voter: voter.into_string(), votes: votes.votes, + cast: votes.cast, }) }) // NIT: collect and into_iter is a bit inefficient... guess it was too complex/confusing otherwise, so fine @@ -246,6 +300,25 @@ pub fn update_tally( update_tallies(storage, gauge, vec![(option, old_vote, new_vote)]) } +/// Completely removes the given option from the tally. +pub fn remove_tally(storage: &mut dyn Storage, gauge: GaugeId, option: &str) -> StdResult<()> { + let old_vote = TALLY.may_load(storage, (gauge, option))?; + + // update main index + TALLY.remove(storage, (gauge, option)); + + if let Some(old_vote) = old_vote { + let total_cast = TOTAL_CAST.may_load(storage, gauge)?.unwrap_or_default(); + // update total cast + TOTAL_CAST.save(storage, gauge, &(total_cast - old_vote))?; + + // update sorted index + OPTION_BY_POINTS.remove(storage, (gauge, old_vote, option)); + } + + Ok(()) +} + /// Updates the tally for one option. /// The first time a user votes, they get `{old_vote: 0, new_vote: power}` /// If they change options, call old option with `{old_vote: power, new_vote: 0}` and new option with `{old_vote: 0, new_vote: power}` @@ -289,7 +362,7 @@ mod tests { use super::*; use cosmwasm_std::Order; - use cosmwasm_std::testing::mock_dependencies; + use cosmwasm_std::testing::{mock_dependencies, mock_env}; const GAUGE: u64 = 2; @@ -345,18 +418,42 @@ mod tests { assert_eq!(total, 750u128); } - fn to_vote_info(voter: &Addr, votes: &[Vote]) -> VoteInfo { + fn to_vote_info(voter: &Addr, votes: &[Vote], cast: impl Into>) -> VoteInfo { VoteInfo { voter: voter.to_string(), votes: votes.to_vec(), + cast: cast.into(), } } #[test] fn votes_works() { let mut deps = mock_dependencies(); + let env = mock_env(); let votes = votes(); + // setup gauges + for gauge_id in 1..=3 { + GAUGES + .save( + &mut deps.storage, + gauge_id, + &Gauge { + title: "test".to_string(), + adapter: Addr::unchecked("gauge_adapter"), + epoch: 100, + min_percent_selected: None, + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: env.block.time.seconds(), + last_executed_set: None, + reset: None, + }, + ) + .unwrap(); + } + let user1 = Addr::unchecked("user1"); let votes1 = vec![Vote { option: "someoption".to_owned(), @@ -366,6 +463,7 @@ mod tests { gauge_id: 1, power: Uint128::new(3), votes: votes1.clone(), + cast: Some(env.block.time.seconds()), }; votes .votes @@ -381,6 +479,7 @@ mod tests { gauge_id: 1, power: Uint128::new(6), votes: votes2.clone(), + cast: Some(env.block.time.seconds()), }; votes .votes @@ -396,6 +495,7 @@ mod tests { gauge_id: 1, power: Uint128::new(9), votes: votes3.clone(), + cast: Some(env.block.time.seconds()), }; votes .votes @@ -410,6 +510,7 @@ mod tests { gauge_id: 2, power: Uint128::new(12), votes: votes4, + cast: Some(env.block.time.seconds()), }; votes .votes @@ -424,6 +525,7 @@ mod tests { gauge_id: 3, power: Uint128::new(15), votes: votes5, + cast: Some(env.block.time.seconds()), }; votes .votes @@ -439,9 +541,9 @@ mod tests { assert_eq!( result, vec![ - to_vote_info(&user1, &votes1), - to_vote_info(&user2, &votes2), - to_vote_info(&user3, &votes3), + to_vote_info(&user1, &votes1, env.block.time.seconds()), + to_vote_info(&user2, &votes2, env.block.time.seconds()), + to_vote_info(&user3, &votes3, env.block.time.seconds()), ] ); @@ -454,10 +556,30 @@ mod tests { #[test] fn query_votes_by_gauge_paginated() { let mut deps = mock_dependencies(); + let env = mock_env(); let votes = votes(); let gauge_id = 1; + GAUGES + .save( + &mut deps.storage, + gauge_id, + &Gauge { + title: "test".to_string(), + adapter: Addr::unchecked("gauge_adapter"), + epoch: 100, + min_percent_selected: None, + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: env.block.time.seconds(), + last_executed_set: None, + reset: None, + }, + ) + .unwrap(); + let user1 = Addr::unchecked("user1"); let votes1 = vec![Vote { option: "someoption".to_owned(), @@ -467,6 +589,7 @@ mod tests { gauge_id: 1, power: Uint128::new(3), votes: votes1.clone(), + cast: Some(env.block.time.seconds()), }; votes .votes @@ -482,6 +605,7 @@ mod tests { gauge_id: 1, power: Uint128::new(6), votes: votes2.clone(), + cast: Some(env.block.time.seconds()), }; votes .votes @@ -497,6 +621,7 @@ mod tests { gauge_id: 1, power: Uint128::new(9), votes: votes3.clone(), + cast: Some(env.block.time.seconds()), }; votes .votes @@ -509,7 +634,10 @@ mod tests { .unwrap(); assert_eq!( result, - vec![to_vote_info(&user1, &votes1), to_vote_info(&user2, &votes2)] + vec![ + to_vote_info(&user1, &votes1, env.block.time.seconds()), + to_vote_info(&user2, &votes2, env.block.time.seconds()) + ] ); // start from second user (start_after user1) @@ -518,13 +646,17 @@ mod tests { .unwrap(); assert_eq!( result, - vec![to_vote_info(&user2, &votes2), to_vote_info(&user3, &votes3)] + vec![ + to_vote_info(&user2, &votes2, env.block.time.seconds()), + to_vote_info(&user3, &votes3, env.block.time.seconds()) + ] ); } #[test] fn query_votes_by_user_paginated() { let mut deps = mock_dependencies(); + let env = mock_env(); let votes = votes(); let user1 = Addr::unchecked("user1"); @@ -535,6 +667,7 @@ mod tests { option: "someoption".to_owned(), weight: Decimal::percent(100), }], + cast: Some(env.block.time.seconds()), }; votes .votes @@ -548,6 +681,7 @@ mod tests { option: "otheroption".to_owned(), weight: Decimal::percent(100), }], + cast: Some(env.block.time.seconds()), }; votes .votes @@ -561,6 +695,7 @@ mod tests { option: "otheroption".to_owned(), weight: Decimal::percent(100), }], + cast: Some(env.block.time.seconds()), }; votes .votes diff --git a/contracts/marketing-gauge-adapter/.cargo/config b/contracts/marketing-gauge-adapter/.cargo/config new file mode 100644 index 000000000..de2d36ac7 --- /dev/null +++ b/contracts/marketing-gauge-adapter/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +wasm-debug = "build --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/marketing-gauge-adapter/Cargo.toml b/contracts/marketing-gauge-adapter/Cargo.toml new file mode 100644 index 000000000..6bc33f775 --- /dev/null +++ b/contracts/marketing-gauge-adapter/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "marketing-gauge-adapter" +authors = ["Jakub "] +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw-utils = { workspace = true } +semver = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cw-multi-test = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } diff --git a/contracts/marketing-gauge-adapter/README.md b/contracts/marketing-gauge-adapter/README.md new file mode 100644 index 000000000..46b14b9bb --- /dev/null +++ b/contracts/marketing-gauge-adapter/README.md @@ -0,0 +1,28 @@ +# Marketing Gauge Adapter Contract + +This is an adapter contract for use in conjunction with the [WYND gauge contract](https://github.com/cosmorama/wynddao/tree/main/contracts/gauge). The purpose of this adapter is to allow people related to marketing to apply for a reward. The total reward amount is set during contract instantiation and will be divided among applicants based on community votes. + +## Implementation + +The basic structure containing all information required for an application is: + +```rust +CreateSubmission { + name: String, + url: String, + address: String, +``` + +Depending on how the gauge contract is instantiated a spam preventing deposit can be required to create a submission. This is specified by the field `required_deposit` in the `Config` structure. + +The contract can receive 3 kind of messages to execute the contract logic: + +1. Create a submission by sending CW20 tokens routed through the CW20 contract. + +2. Create a submission by sending native tokens. + +3. Return all submission's deposit to the address specified during submission. This logic can be triggered only by the contract's `admin` specified during gauge instantiation and saved in the `Config` structure. + +## Options + +Options represent all the addresses that have been stored through the field `address` of the `CreateSubmission` structure. diff --git a/contracts/marketing-gauge-adapter/src/bin/schema.rs b/contracts/marketing-gauge-adapter/src/bin/schema.rs new file mode 100644 index 000000000..52c8e3398 --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/bin/schema.rs @@ -0,0 +1,13 @@ +use cosmwasm_schema::write_api; + +use cosmwasm_std::Empty; +use marketing_gauge_adapter::msg::{AdapterQueryMsg, InstantiateMsg, MigrateMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: Empty, + query: AdapterQueryMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/marketing-gauge-adapter/src/contract.rs b/contracts/marketing-gauge-adapter/src/contract.rs new file mode 100644 index 000000000..76b2ea9ce --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/contract.rs @@ -0,0 +1,515 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + coins, from_binary, to_binary, Addr, BankMsg, Binary, CosmosMsg, Deps, DepsMut, Env, + MessageInfo, Order, Response, StdError, StdResult, Uint128, WasmMsg, +}; +use cw2::set_contract_version; +use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg}; + +use crate::error::ContractError; +use crate::msg::{AdapterQueryMsg, ExecuteMsg, InstantiateMsg, MigrateMsg, ReceiveMsg}; +use crate::state::{AssetType, Config, Submission, CONFIG, SUBMISSIONS}; + +// Version info for migration info. +const CONTRACT_NAME: &str = "crates.io:marketing-gauge-adapter"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let community_pool = deps.api.addr_validate(&msg.community_pool)?; + SUBMISSIONS.save( + deps.storage, + community_pool.clone(), + &Submission { + sender: env.contract.address, + name: "Unimpressed".to_owned(), + url: "Those funds go back to the community pool".to_owned(), + }, + )?; + + let config = Config { + admin: deps.api.addr_validate(&msg.admin)?, + required_deposit: msg.required_deposit, + community_pool, + reward: msg.reward, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Receive(msg) => receive_cw20_message(deps, info, msg), + ExecuteMsg::CreateSubmission { name, url, address } => { + let received = info.funds.into_iter().next().unwrap_or_default(); + execute::create_submission( + deps, + info.sender, + name, + url, + address, + received.amount, + AssetType::Native(received.denom), + ) + } + ExecuteMsg::ReturnDeposits {} => execute::return_deposits(deps, info.sender), + } +} + +fn receive_cw20_message( + deps: DepsMut, + info: MessageInfo, + msg: Cw20ReceiveMsg, +) -> Result { + match from_binary(&msg.msg)? { + ReceiveMsg::CreateSubmission { name, url, address } => { + let denom = AssetType::Cw20(info.sender.to_string()); + execute::create_submission( + deps, + Addr::unchecked(msg.sender), + name, + url, + address, + msg.amount, + denom, + ) + } + } +} + +fn create_bank_msg( + denom: &AssetType, + amount: Uint128, + recipient: Addr, +) -> Result { + match denom { + AssetType::Cw20(address) => Ok(WasmMsg::Execute { + contract_addr: address.to_string(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: recipient.to_string(), + amount, + })?, + funds: vec![], + } + .into()), + AssetType::Native(denom) => { + let amount = coins(amount.u128(), denom); + Ok(BankMsg::Send { + to_address: recipient.to_string(), + amount, + } + .into()) + } + } +} + +pub mod execute { + use super::*; + + use cosmwasm_std::{ensure_eq, CosmosMsg}; + + pub fn create_submission( + deps: DepsMut, + sender: Addr, + name: String, + url: String, + address: String, + received_amount: Uint128, + received_denom: AssetType, + ) -> Result { + let address = deps.api.addr_validate(&address)?; + + let Config { + required_deposit, + community_pool: _, + reward: _, + admin: _, + } = CONFIG.load(deps.storage)?; + if let Some(required_deposit) = required_deposit { + if AssetType::Native("".into()) == received_denom { + return Err(ContractError::DepositRequired {}); + } + if required_deposit.denom != received_denom { + return Err(ContractError::InvalidDepositType {}); + } + if received_amount != required_deposit.amount { + return Err(ContractError::InvalidDepositAmount { + correct_amount: required_deposit.amount, + }); + } + } else { + // If no deposit is required, then any deposit invalidates a submission. + if !received_amount.is_zero() { + return Err(ContractError::InvalidDepositAmount { + correct_amount: Uint128::zero(), + }); + } + } + + // allow to overwrite submission by the same author + if let Some(old_submission) = SUBMISSIONS.may_load(deps.storage, address.clone())? { + if old_submission.sender != sender { + return Err(ContractError::UnauthorizedSubmission {}); + } + } + + SUBMISSIONS.save(deps.storage, address, &Submission { sender, name, url })?; + Ok(Response::new().add_attribute("create", "submission")) + } + + pub fn return_deposits(deps: DepsMut, sender: Addr) -> Result { + let Config { + admin, + required_deposit, + community_pool: _, + reward: _, + } = CONFIG.load(deps.storage)?; + + // No refund if no deposit was required. + let required_deposit = required_deposit.ok_or(ContractError::NoDepositToRefund {})?; + + ensure_eq!(sender, admin, ContractError::Unauthorized {}); + + let msgs = SUBMISSIONS + .range(deps.storage, None, None, Order::Ascending) + .map(|item| { + let (_submission_recipient, submission) = item?; + create_bank_msg( + &required_deposit.denom, + required_deposit.amount, + submission.sender, + ) + }) + .collect::>>()?; + + Ok(Response::new().add_messages(msgs)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: AdapterQueryMsg) -> StdResult { + match msg { + AdapterQueryMsg::Config {} => to_binary(&CONFIG.load(deps.storage)?), + AdapterQueryMsg::AllOptions {} => to_binary(&query::all_options(deps)?), + AdapterQueryMsg::CheckOption { option } => to_binary(&query::check_option(deps, option)?), + AdapterQueryMsg::SampleGaugeMsgs { selected } => { + to_binary(&query::sample_gauge_msgs(deps, selected)?) + } + AdapterQueryMsg::Submission { address } => to_binary(&query::submission(deps, address)?), + AdapterQueryMsg::AllSubmissions {} => to_binary(&query::all_submissions(deps)?), + } +} + +mod query { + use cosmwasm_std::{CosmosMsg, Decimal}; + + use crate::msg::{ + AllOptionsResponse, AllSubmissionsResponse, CheckOptionResponse, SampleGaugeMsgsResponse, + SubmissionResponse, + }; + + use super::*; + + pub fn all_options(deps: Deps) -> StdResult { + Ok(AllOptionsResponse { + options: SUBMISSIONS + .keys(deps.storage, None, None, Order::Ascending) + .map(|key| Ok(key?.to_string())) + .collect::>>()?, + }) + } + + pub fn check_option(deps: Deps, option: String) -> StdResult { + Ok(CheckOptionResponse { + valid: SUBMISSIONS.has(deps.storage, deps.api.addr_validate(&option)?), + }) + } + + pub fn sample_gauge_msgs( + deps: Deps, + winners: Vec<(String, Decimal)>, + ) -> StdResult { + let reward = CONFIG.load(deps.storage)?.reward; + + let execute = winners + .into_iter() + .map(|(to_address, fraction)| { + // Gauge already sents chosen tally to this query by using results we send in + // all_options query; they are already validated + create_bank_msg( + &reward.denom, + fraction * reward.amount, + Addr::unchecked(to_address), + ) + }) + .collect::>>()?; + Ok(SampleGaugeMsgsResponse { execute }) + } + + pub fn submission(deps: Deps, address: String) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let submission = SUBMISSIONS.load(deps.storage, address.clone())?; + Ok(SubmissionResponse { + sender: submission.sender, + name: submission.name, + url: submission.url, + address, + }) + } + + pub fn all_submissions(deps: Deps) -> StdResult { + Ok(AllSubmissionsResponse { + submissions: SUBMISSIONS + .range(deps.storage, None, None, Order::Ascending) + .map(|s| { + let (address, submission) = s?; + Ok(SubmissionResponse { + sender: submission.sender, + name: submission.name, + url: submission.url, + address, + }) + }) + .collect::>>()?, + }) + } +} + +/// Manages the contract migration. +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + Ok(Response::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use cosmwasm_std::{ + testing::{mock_dependencies, mock_env, mock_info}, + Decimal, Uint128, + }; + + use crate::state::Asset; + + #[test] + fn proper_initialization() { + let mut deps = mock_dependencies(); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), + community_pool: "community".to_owned(), + reward: Asset::new_native("ujuno", 150_000_000_000), + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + + // Check if the config is stored. + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.admin, Addr::unchecked("admin")); + assert_eq!( + config.required_deposit, + Some(Asset { + denom: AssetType::Cw20("wynd".to_owned()), + amount: Uint128::new(10_000_000) + }) + ); + assert_eq!(config.community_pool, "community".to_owned()); + assert_eq!( + config.reward, + Asset { + denom: AssetType::Native("ujuno".to_owned()), + amount: Uint128::new(150_000_000_000) + } + ); + + let msg = InstantiateMsg { + reward: Asset::new_native("ujuno", 10_000_000), + ..msg + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!( + config.reward, + Asset { + denom: AssetType::Native("ujuno".to_owned()), + amount: Uint128::new(10_000_000) + } + ); + + let msg = InstantiateMsg { + required_deposit: None, + ..msg + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.required_deposit, None); + } + + #[test] + fn sample_gauge_msgs_native() { + let mut deps = mock_dependencies(); + + let reward = Uint128::new(150_000_000_000); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), + community_pool: "community".to_owned(), + reward: Asset::new_native("ujuno", reward.into()), + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + + let selected = vec![ + ( + "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + Decimal::percent(41), + ), + ( + "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + Decimal::percent(33), + ), + ( + "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + Decimal::percent(26), + ), + ]; + let res = query::sample_gauge_msgs(deps.as_ref(), selected).unwrap(); + assert_eq!(res.execute.len(), 3); + assert_eq!( + res.execute, + [ + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + amount: coins((reward * Decimal::percent(41)).u128(), "ujuno") + }), + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + amount: coins((reward * Decimal::percent(33)).u128(), "ujuno") + }), + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + amount: coins((reward * Decimal::percent(26)).u128(), "ujuno") + }), + ] + ); + } + + #[test] + fn sample_gauge_msgs_cw20() { + let mut deps = mock_dependencies(); + + let reward = Uint128::new(150_000_000_000); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), + community_pool: "community".to_owned(), + reward: Asset::new_cw20("wynd", reward.into()), + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + + let selected = vec![ + ( + "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + Decimal::percent(41), + ), + ( + "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + Decimal::percent(33), + ), + ( + "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + Decimal::percent(26), + ), + ]; + let res = query::sample_gauge_msgs(deps.as_ref(), selected).unwrap(); + assert_eq!(res.execute.len(), 3); + assert_eq!( + res.execute, + [ + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "wynd".to_owned(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + amount: reward * Decimal::percent(41) + }) + .unwrap(), + funds: vec![] + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "wynd".to_owned(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + amount: reward * Decimal::percent(33) + }) + .unwrap(), + funds: vec![] + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "wynd".to_owned(), + msg: to_binary(&Cw20ExecuteMsg::Transfer { + recipient: "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + amount: reward * Decimal::percent(26) + }) + .unwrap(), + funds: vec![] + }), + ] + ); + } + + #[test] + fn return_deposits_authorization() { + let mut deps = mock_dependencies(); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: None, + community_pool: "community".to_owned(), + reward: Asset::new_native("ujuno", 150_000_000_000), + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + + let err = execute::return_deposits(deps.as_mut(), Addr::unchecked("user")).unwrap_err(); + assert_eq!(err, ContractError::NoDepositToRefund {}); + + let msg = InstantiateMsg { + required_deposit: Some(Asset::new_native("ujuno", 10_000_000)), + ..msg + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + + let err = execute::return_deposits(deps.as_mut(), Addr::unchecked("user")).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } +} diff --git a/contracts/marketing-gauge-adapter/src/error.rs b/contracts/marketing-gauge-adapter/src/error.rs new file mode 100644 index 000000000..75521219d --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/error.rs @@ -0,0 +1,26 @@ +use cosmwasm_std::{StdError, Uint128}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Operation unauthorized - only admin can release deposits")] + Unauthorized {}, + + #[error("Operation unauthorized - there's already existing submission for that destination address; only previous sender can overwrite it")] + UnauthorizedSubmission {}, + + #[error("Invalid submission - required deposit set in incorrect denom")] + InvalidDepositType {}, + + #[error("Invalid submission - invalid amount for required deposit. Either multiple denoms were sent or amount does not match {correct_amount}")] + InvalidDepositAmount { correct_amount: Uint128 }, + + #[error("No deposit was required, therefore no deposit can be returned")] + NoDepositToRefund {}, + + #[error("Deposit required, cannot create submission.")] + DepositRequired {}, +} diff --git a/contracts/marketing-gauge-adapter/src/lib.rs b/contracts/marketing-gauge-adapter/src/lib.rs new file mode 100644 index 000000000..31d9a5aa4 --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod multitest; + +pub use crate::error::ContractError; diff --git a/contracts/marketing-gauge-adapter/src/msg.rs b/contracts/marketing-gauge-adapter/src/msg.rs new file mode 100644 index 000000000..dc1ae2d7c --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/msg.rs @@ -0,0 +1,99 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, CosmosMsg, Decimal}; +use cw20::Cw20ReceiveMsg; + +use crate::state::Asset; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address that is allowed to return deposits. + pub admin: String, + /// Deposit required for valid submission. This option allows to reduce spam. + pub required_deposit: Option, + /// Address of contract where each deposit is transferred. + pub community_pool: String, + /// Total reward amount. + pub reward: Asset, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Implements the Cw20 receiver interface. + Receive(Cw20ReceiveMsg), + /// Save info about team that wants to participate. + /// Only for native tokens as required deposit. + CreateSubmission { + name: String, + url: String, + address: String, + }, + /// Sends back all deposit to senders. + ReturnDeposits {}, +} + +#[cw_serde] +pub enum ReceiveMsg { + /// Save info about team that wants to participate. + /// Only for CW20 tokens as required deposit. + CreateSubmission { + name: String, + url: String, + address: String, + }, +} + +#[cw_serde] +pub enum MigrateMsg {} + +// Queries copied from gauge-orchestrator for now (we could use a common crate for this). +/// Queries the gauge requires from the adapter contract in order to function. +#[cw_serde] +#[derive(QueryResponses)] +pub enum AdapterQueryMsg { + #[returns(crate::state::Config)] + Config {}, + #[returns(AllOptionsResponse)] + AllOptions {}, + #[returns(CheckOptionResponse)] + CheckOption { option: String }, + #[returns(SampleGaugeMsgsResponse)] + SampleGaugeMsgs { + /// Option along with weight. + /// Sum of all weights should be 1.0 (within rounding error). + selected: Vec<(String, Decimal)>, + }, + + // Marketing-gauge specific queries to help on frontend + #[returns(SubmissionResponse)] + Submission { address: String }, + #[returns(AllSubmissionsResponse)] + AllSubmissions {}, +} + +#[cw_serde] +pub struct AllOptionsResponse { + pub options: Vec, +} + +#[cw_serde] +pub struct CheckOptionResponse { + pub valid: bool, +} + +#[cw_serde] +pub struct SampleGaugeMsgsResponse { + pub execute: Vec, +} + +#[cw_serde] +pub struct SubmissionResponse { + pub sender: Addr, + pub name: String, + pub url: String, + pub address: Addr, +} + +#[cw_serde] +pub struct AllSubmissionsResponse { + pub submissions: Vec, +} diff --git a/contracts/marketing-gauge-adapter/src/multitest/mod.rs b/contracts/marketing-gauge-adapter/src/multitest/mod.rs new file mode 100644 index 000000000..e5a2ee02d --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/multitest/mod.rs @@ -0,0 +1,4 @@ +mod suite; + +mod options; +mod submission; diff --git a/contracts/marketing-gauge-adapter/src/multitest/options.rs b/contracts/marketing-gauge-adapter/src/multitest/options.rs new file mode 100644 index 000000000..d1c57e6cf --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/multitest/options.rs @@ -0,0 +1,50 @@ +use cosmwasm_std::{coin, Addr}; + +use crate::multitest::suite::SuiteBuilder; + +#[test] +fn option_queries() { + let mut suite = SuiteBuilder::new() + .with_community_pool("community_pool") + .with_funds("owner", &[coin(100_000, "juno")]) + .with_funds("einstein", &[coin(100_000, "juno")]) + .with_native_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + let options = suite.query_all_options().unwrap(); + // account for a default option + assert_eq!(options.len(), 1); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + // Valid submission. + suite + .execute_create_submission( + Addr::unchecked("einstein"), + "MIBers".to_owned(), + "https://www.mib.tech/".to_owned(), + "einstein".to_owned(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + let options = suite.query_all_options().unwrap(); + assert_eq!(options, vec!["community_pool", "einstein", &recipient],); + + let option = suite.query_check_option("einstein".to_owned()).unwrap(); + assert!(option); + + let option = suite.query_check_option("newton".to_owned()).unwrap(); + assert!(!option); +} diff --git a/contracts/marketing-gauge-adapter/src/multitest/submission.rs b/contracts/marketing-gauge-adapter/src/multitest/submission.rs new file mode 100644 index 000000000..4243ace50 --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/multitest/submission.rs @@ -0,0 +1,484 @@ +use crate::{msg::SubmissionResponse, ContractError}; + +use super::suite::SuiteBuilder; + +use cosmwasm_std::{coin, Addr, Uint128}; + +#[test] +fn create_default_submission() { + let suite = SuiteBuilder::new() + .with_community_pool("community_pool") + .build(); + + // this one is created by default during instantiation + assert_eq!( + SubmissionResponse { + sender: suite.gauge_adapter.clone(), + name: "Unimpressed".to_owned(), + url: "Those funds go back to the community pool".to_owned(), + address: Addr::unchecked("community_pool"), + }, + suite.query_submission("community_pool".to_owned()).unwrap() + ) +} + +#[test] +fn create_submission_no_required_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .build(); + + let recipient = "user".to_owned(); + + // Fails send funds along with the tx. + let err = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::zero() + }, + err.downcast().unwrap() + ); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: suite.owner.clone(), + name: "WYNDers".to_owned(), + url: "https://www.wynddao.com/".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + suite.query_submission(recipient).unwrap() + ) +} + +#[test] +fn overwrite_existing_submission() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .build(); + + let recipient = "user".to_owned(); + + suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: suite.owner.clone(), + name: "WYNDers".to_owned(), + url: "https://www.wynddao.com/".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + suite.query_submission(recipient.clone()).unwrap() + ); + + // Try to submit to the same address with different user + let err = suite + .execute_create_submission( + Addr::unchecked("anotheruser"), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap_err(); + assert_eq!( + ContractError::UnauthorizedSubmission {}, + err.downcast().unwrap() + ); + + // Overwriting submission as same author works + let err = suite + .execute_create_submission( + Addr::unchecked("anotheruser"), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap_err(); + assert_eq!( + ContractError::UnauthorizedSubmission {}, + err.downcast().unwrap() + ); + + suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "wynddao".to_owned(), + recipient.clone(), + &[], + ) + .unwrap(); + + let response = suite.query_submission(recipient).unwrap(); + assert_eq!(response.url, "wynddao".to_owned()); +} + +#[test] +fn create_submission_required_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno"), coin(100_000, "wynd")]) + .with_native_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + // Fails if no funds sent. + let err = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap_err(); + + assert_eq!(ContractError::DepositRequired {}, err.downcast().unwrap()); + + // Fails if correct denom but not enought amount. + let err = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1, "juno")], + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::new(1_000) + }, + err.downcast().unwrap() + ); + + // Fails if enough amount but incorrect denom. + let err = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "wynd")], + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositType {}, + err.downcast().unwrap() + ); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: suite.owner.clone(), + name: "WYNDers".to_owned(), + url: "https://www.wynddao.com/".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + suite.query_submission(recipient).unwrap() + ) +} + +#[test] +fn create_receive_required_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .with_cw20_funds("owner", 1_000) + .with_cw20_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + let cw20_addr = suite.instantiate_token(suite.owner.clone().as_ref(), "moonbites", 1_000_000); + + // Fails by sending wrong cw20. + let err = suite + .execute_receive_through_cw20( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + 1_000, + cw20_addr, + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositType {}, + err.downcast().unwrap(), + ); + + // Fails by sending less tokens than required. + let err = suite + .execute_receive_through_cw20( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + 1, + suite.default_cw20.clone(), + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::new(1_000) + }, + err.downcast().unwrap() + ); + + // Valid submission. + _ = suite + .execute_receive_through_cw20( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + 1_000, + suite.default_cw20.clone(), + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: suite.owner.clone(), + name: "WYNDers".to_owned(), + url: "https://www.wynddao.com/".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + suite.query_submission(recipient).unwrap() + ); + + assert_eq!(2, suite.query_submissions().unwrap().len()) +} + +#[test] +fn return_deposits_no_required_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .build(); + + let err = suite + .execute_return_deposit(suite.owner.clone().as_ref()) + .unwrap_err(); + + assert_eq!(ContractError::NoDepositToRefund {}, err.downcast().unwrap()) +} + +#[test] +fn return_deposits_no_admin() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .with_native_deposit(1_000) + .build(); + + let err = suite.execute_return_deposit("einstein").unwrap_err(); + + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()) +} + +#[test] +fn return_deposits_required_native_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(1_000, "juno")]) + .with_native_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + assert_eq!( + suite.query_native_balance(suite.owner.as_ref()).unwrap(), + 0u128, + ); + assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); + assert_eq!( + suite + .query_native_balance(suite.gauge_adapter.as_ref()) + .unwrap(), + 1_000u128, + ); + + _ = suite + .execute_return_deposit(suite.owner.clone().as_ref()) + .unwrap(); + + assert_eq!( + suite.query_native_balance(suite.owner.as_ref()).unwrap(), + 1_000u128, + ); + assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); + assert_eq!( + suite + .query_native_balance(suite.gauge_adapter.as_ref()) + .unwrap(), + 0u128, + ); +} + +#[test] +fn return_deposits_required_native_deposit_multiple_deposits() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(1_000, "juno")]) + .with_funds("einstein", &[coin(1_000, "juno")]) + .with_native_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + // Valid submission. + _ = suite + .execute_create_submission( + Addr::unchecked("einstein"), + "MIBers".to_owned(), + "https://www.mib.tech/".to_owned(), + "einstein".to_owned(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + _ = suite + .execute_return_deposit(suite.owner.clone().as_ref()) + .unwrap(); + + assert_eq!( + suite.query_native_balance(suite.owner.as_ref()).unwrap(), + 1_000u128, + ); + assert_eq!(suite.query_native_balance("einstein").unwrap(), 1_000u128,); + assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); + assert_eq!( + suite + .query_native_balance(suite.gauge_adapter.as_ref()) + .unwrap(), + 0u128, + ); +} + +#[test] +fn return_deposits_required_cw20_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(1_000, "juno")]) + .with_cw20_funds("owner", 1_000) + .with_cw20_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + // Valid submission. + _ = suite + .execute_receive_through_cw20( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + 1_000, + suite.default_cw20.clone(), + ) + .unwrap(); + + assert_eq!( + suite + .query_cw20_balance(suite.owner.as_ref(), &suite.default_cw20) + .unwrap(), + 0u128, + ); + assert_eq!( + suite + .query_cw20_balance(&recipient, &suite.default_cw20) + .unwrap(), + 0u128, + ); + assert_eq!( + suite + .query_cw20_balance(suite.gauge_adapter.as_ref(), &suite.default_cw20) + .unwrap(), + 1_000u128, + ); + + _ = suite + .execute_return_deposit(suite.owner.clone().as_ref()) + .unwrap(); + + assert_eq!( + suite + .query_cw20_balance(suite.owner.as_ref(), &suite.default_cw20) + .unwrap(), + 1_000u128, + ); + // Tokens are sent back to the address specified in the sumbission. + assert_eq!( + suite + .query_cw20_balance(&recipient, &suite.default_cw20) + .unwrap(), + 0u128, + ); + assert_eq!( + suite + .query_cw20_balance(suite.gauge_adapter.as_ref(), &suite.default_cw20) + .unwrap(), + 0u128, + ); +} diff --git a/contracts/marketing-gauge-adapter/src/multitest/suite.rs b/contracts/marketing-gauge-adapter/src/multitest/suite.rs new file mode 100644 index 000000000..67357b9d4 --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/multitest/suite.rs @@ -0,0 +1,332 @@ +use cosmwasm_std::{to_binary, Addr, Binary, Coin, Uint128}; +use cw20::{BalanceResponse, Cw20QueryMsg}; +use cw20::{Cw20Coin, MinterResponse}; +use cw_multi_test::{App, AppResponse, ContractWrapper, Executor}; + +use anyhow::Result as AnyResult; +use cw20_base::msg::ExecuteMsg as Cw20BaseExecuteMsg; +use cw20_base::msg::InstantiateMsg as Cw20BaseInstantiateMsg; + +use crate::msg::CheckOptionResponse; +use crate::{ + msg::{ + AdapterQueryMsg, AllOptionsResponse, AllSubmissionsResponse, ExecuteMsg, ReceiveMsg, + SubmissionResponse, + }, + state::{Asset, AssetType}, +}; + +pub const NATIVE: &str = "juno"; +pub const CW20: &str = "wynd"; +pub const OWNER: &str = "owner"; + +// Store the marketing gauge adapter contract and returns the code id. +fn store_gauge_adapter(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + )); + + app.store_code(contract) +} + +// Store the cw20 contract and returns the code id. +fn store_cw20(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + )); + + app.store_code(contract) +} + +#[derive(Debug)] +pub struct SuiteBuilder { + // Gauge adapter's instantiate params + community_pool: String, + required_deposit: Option, + reward: Asset, + funds: Vec<(Addr, Vec)>, + cw20_funds: Vec, +} + +impl SuiteBuilder { + pub fn new() -> Self { + Self { + community_pool: "community".to_owned(), + required_deposit: None, + reward: Asset { + denom: AssetType::Native(NATIVE.into()), + amount: Uint128::new(1_000_000), + }, + funds: vec![], + cw20_funds: vec![], + } + } + + pub fn with_community_pool(mut self, community_pool: &str) -> Self { + self.community_pool = community_pool.to_owned(); + self + } + + // Allows to initialize the suite with native coins associated to an address. + pub fn with_funds(mut self, addr: &str, funds: &[Coin]) -> Self { + self.funds.push((Addr::unchecked(addr), funds.into())); + self + } + + // Allows to initialize the suite with default cw20 tokens associated to an address. + pub fn with_cw20_funds(mut self, addr: &str, amount: u128) -> Self { + self.cw20_funds.push(Cw20Coin { + address: addr.into(), + amount: Uint128::from(amount), + }); + self + } + + // Allows to initialize the marketing gauge adapter with required native coins in the config. + pub fn with_native_deposit(mut self, amount: u128) -> Self { + self.required_deposit = Some(Asset { + denom: AssetType::Native(NATIVE.into()), + amount: Uint128::from(amount), + }); + self + } + + // Allows to initialize the marketing gauge adapter with required cw20 tokens in the config. + pub fn with_cw20_deposit(mut self, amount: u128) -> Self { + self.required_deposit = Some(Asset { + denom: AssetType::Cw20("contract1".to_string()), + amount: Uint128::from(amount), + }); + self + } + + // Instantiate a marketing gauge adapter and returns its address. + fn instantiate_marketing_gauge_adapter( + &self, + app: &mut App, + gauge_id: u64, + owner: &str, + ) -> Addr { + app.instantiate_contract( + gauge_id, + Addr::unchecked(owner), + &crate::msg::InstantiateMsg { + admin: owner.to_owned(), + required_deposit: self.required_deposit.clone(), + community_pool: self.community_pool.clone(), + reward: self.reward.clone(), + }, + &[], + "Marketing Gauge Adapter", + None, + ) + .unwrap() + } + + // Instantiate a cw20 and returns its address. + fn instantiate_default_cw20(&self, app: &mut App, cw20_code_id: u64, owner: &str) -> Addr { + app.instantiate_contract( + cw20_code_id, + Addr::unchecked(owner), + &Cw20BaseInstantiateMsg { + name: CW20.to_owned(), + symbol: CW20.to_owned(), + decimals: 6, + initial_balances: self.cw20_funds.clone(), + mint: Some(MinterResponse { + minter: owner.to_string(), + cap: None, + }), + marketing: None, + }, + &[], + CW20.to_owned(), + None, + ) + .unwrap() + } + + #[track_caller] + pub fn build(self) -> Suite { + let mut app = App::default(); + let owner = Addr::unchecked(OWNER); + + // Store required contracts. + let cw20_code_id = store_cw20(&mut app); + let gauge_adapter_code_id = store_gauge_adapter(&mut app); + + // Instantiate default contracts. + let gauge_adapter_addr = + self.instantiate_marketing_gauge_adapter(&mut app, gauge_adapter_code_id, OWNER); + + // cw20 address must be "contract1" otherwise cannot use `with_cw20_deposit()`. Not very + // elegant but do its job. + let cw20_addr = self.instantiate_default_cw20(&mut app, cw20_code_id, OWNER); + + // Mint initial native token if any. + app.init_modules(|router, _, storage| -> AnyResult<()> { + for (addr, coin) in self.funds { + router.bank.init_balance(storage, &addr, coin)?; + } + Ok(()) + }) + .unwrap(); + + Suite { + owner, + app, + gauge_adapter: gauge_adapter_addr, + default_cw20: cw20_addr, + cw20_code_id, + } + } +} + +pub struct Suite { + pub owner: Addr, + pub app: App, + pub gauge_adapter: Addr, + pub default_cw20: Addr, + // This is stored to instantiate other cw20 tokens in tests. + cw20_code_id: u64, +} + +impl Suite { + // --------------------------------------------------------------------------------------------- + // Execute + // --------------------------------------------------------------------------------------------- + pub fn execute_create_submission( + &mut self, + sender: Addr, + name: String, + url: String, + address: String, + funds: &[Coin], + ) -> AnyResult { + self.app.execute_contract( + sender, + self.gauge_adapter.clone(), + &ExecuteMsg::CreateSubmission { name, url, address }, + funds, + ) + } + + pub fn execute_receive_through_cw20( + &mut self, + sender: Addr, + name: String, + url: String, + address: String, + // amount refers to CW20 amount. + amount: u128, + cw20_addr: Addr, + ) -> AnyResult { + let msg: Binary = to_binary(&ReceiveMsg::CreateSubmission { name, url, address })?; + + self.app.execute_contract( + sender, + cw20_addr, + &Cw20BaseExecuteMsg::Send { + contract: self.gauge_adapter.to_string(), + amount: Uint128::from(amount), + msg, + }, + &[], + ) + } + + pub fn execute_return_deposit(&mut self, sender: &str) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + self.gauge_adapter.clone(), + &ExecuteMsg::ReturnDeposits {}, + &[], + ) + } + + // --------------------------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------------------------- + pub fn query_submission(&self, address: String) -> AnyResult { + Ok(self.app.wrap().query_wasm_smart( + self.gauge_adapter.clone(), + &AdapterQueryMsg::Submission { address }, + )?) + } + + pub fn query_submissions(&self) -> AnyResult> { + let res: AllSubmissionsResponse = self.app.wrap().query_wasm_smart( + self.gauge_adapter.clone(), + &AdapterQueryMsg::AllSubmissions {}, + )?; + + Ok(res.submissions) + } + + pub fn query_all_options(&self) -> AnyResult> { + let res: AllOptionsResponse = self + .app + .wrap() + .query_wasm_smart(self.gauge_adapter.clone(), &AdapterQueryMsg::AllOptions {})?; + + Ok(res.options) + } + + pub fn query_check_option(&self, option: String) -> AnyResult { + let res: CheckOptionResponse = self.app.wrap().query_wasm_smart( + self.gauge_adapter.clone(), + &AdapterQueryMsg::CheckOption { option }, + )?; + + Ok(res.valid) + } + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + // Instantiate a cw20 token and assign to address a specific amount. + pub fn instantiate_token(&mut self, address: &str, token: &str, amount: u128) -> Addr { + self.app + .instantiate_contract( + self.cw20_code_id, + Addr::unchecked(address), + &Cw20BaseInstantiateMsg { + name: token.to_owned(), + symbol: token.to_owned(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: address.to_owned(), + amount: Uint128::from(amount), + }], + mint: None, + marketing: None, + }, + &[], + token, + None, + ) + .unwrap() + } + + pub fn query_cw20_balance(&self, user: &str, contract: &Addr) -> AnyResult { + let balance: BalanceResponse = self.app.wrap().query_wasm_smart( + contract, + &Cw20QueryMsg::Balance { + address: user.to_owned(), + }, + )?; + + Ok(balance.balance.into()) + } + + pub fn query_native_balance(&self, user: &str) -> AnyResult { + let balance = self.app.wrap().query_balance(user, NATIVE)?; + + Ok(balance.amount.into()) + } +} diff --git a/contracts/marketing-gauge-adapter/src/state.rs b/contracts/marketing-gauge-adapter/src/state.rs new file mode 100644 index 000000000..e2d836943 --- /dev/null +++ b/contracts/marketing-gauge-adapter/src/state.rs @@ -0,0 +1,55 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::{Item, Map}; + +#[cw_serde] +pub struct Config { + /// Address that is allowed to return deposits. + pub admin: Addr, + /// Deposit required for valid submission. + pub required_deposit: Option, + /// Address of contract where each deposit is transferred. + pub community_pool: Addr, + /// Total reward amount. + pub reward: Asset, +} + +pub const CONFIG: Item = Item::new("config"); + +#[cw_serde] +pub enum AssetType { + Native(String), + Cw20(String), +} + +#[cw_serde] +pub struct Asset { + pub denom: AssetType, + pub amount: Uint128, +} + +impl Asset { + pub fn new_native(denom: &str, amount: u128) -> Self { + Self { + denom: AssetType::Native(denom.to_owned()), + amount: amount.into(), + } + } + + pub fn new_cw20(denom: &str, amount: u128) -> Self { + Self { + denom: AssetType::Cw20(denom.to_owned()), + amount: amount.into(), + } + } +} + +#[cw_serde] +pub struct Submission { + pub sender: Addr, + pub name: String, + pub url: String, +} + +// All submissions indexed by submition's fund destination address. +pub const SUBMISSIONS: Map = Map::new("submissions"); From 515b900909eeb4e65683f54a595ad6e01dac141c Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Thu, 4 Jul 2024 15:14:21 -0400 Subject: [PATCH 06/12] gauge tests pass --- Cargo.lock | 45 +- Cargo.toml | 4 + contracts/gauges/README.md | 3 + contracts/gauges/gauge-adapter/.cargo/config | 5 + contracts/gauges/gauge-adapter/Cargo.toml | 34 + contracts/gauges/gauge-adapter/README.md | 28 + .../gauges/gauge-adapter/src/bin/schema.rs | 13 + .../gauges/gauge-adapter/src/contract.rs | 519 +++++++++++ contracts/gauges/gauge-adapter/src/error.rs | 26 + contracts/gauges/gauge-adapter/src/lib.rs | 9 + contracts/gauges/gauge-adapter/src/msg.rs | 99 +++ .../gauges/gauge-adapter/src/multitest/mod.rs | 4 + .../gauge-adapter/src/multitest/options.rs | 50 ++ .../gauge-adapter/src/multitest/submission.rs | 484 ++++++++++ .../gauge-adapter/src/multitest/suite.rs | 332 +++++++ contracts/gauges/gauge-adapter/src/state.rs | 55 ++ contracts/gauges/gauge/.cargo/config | 4 + contracts/gauges/gauge/Cargo.toml | 34 + contracts/gauges/gauge/README.md | 92 ++ contracts/gauges/gauge/src/bin/schema.rs | 11 + contracts/gauges/gauge/src/contract.rs | 840 ++++++++++++++++++ contracts/gauges/gauge/src/error.rs | 63 ++ contracts/gauges/gauge/src/helpers.rs | 27 + contracts/gauges/gauge/src/lib.rs | 9 + contracts/gauges/gauge/src/msg.rs | 259 ++++++ .../gauges/gauge/src/multitest/adapter.rs | 100 +++ contracts/gauges/gauge/src/multitest/gauge.rs | 792 +++++++++++++++++ contracts/gauges/gauge/src/multitest/mod.rs | 6 + contracts/gauges/gauge/src/multitest/reset.rs | 417 +++++++++ contracts/gauges/gauge/src/multitest/suite.rs | 713 +++++++++++++++ contracts/gauges/gauge/src/multitest/tally.rs | 333 +++++++ .../gauges/gauge/src/multitest/voting.rs | 674 ++++++++++++++ contracts/gauges/gauge/src/state.rs | 717 +++++++++++++++ 33 files changed, 6799 insertions(+), 2 deletions(-) create mode 100644 contracts/gauges/README.md create mode 100644 contracts/gauges/gauge-adapter/.cargo/config create mode 100644 contracts/gauges/gauge-adapter/Cargo.toml create mode 100644 contracts/gauges/gauge-adapter/README.md create mode 100644 contracts/gauges/gauge-adapter/src/bin/schema.rs create mode 100644 contracts/gauges/gauge-adapter/src/contract.rs create mode 100644 contracts/gauges/gauge-adapter/src/error.rs create mode 100644 contracts/gauges/gauge-adapter/src/lib.rs create mode 100644 contracts/gauges/gauge-adapter/src/msg.rs create mode 100644 contracts/gauges/gauge-adapter/src/multitest/mod.rs create mode 100644 contracts/gauges/gauge-adapter/src/multitest/options.rs create mode 100644 contracts/gauges/gauge-adapter/src/multitest/submission.rs create mode 100644 contracts/gauges/gauge-adapter/src/multitest/suite.rs create mode 100644 contracts/gauges/gauge-adapter/src/state.rs create mode 100644 contracts/gauges/gauge/.cargo/config create mode 100644 contracts/gauges/gauge/Cargo.toml create mode 100644 contracts/gauges/gauge/README.md create mode 100644 contracts/gauges/gauge/src/bin/schema.rs create mode 100644 contracts/gauges/gauge/src/contract.rs create mode 100644 contracts/gauges/gauge/src/error.rs create mode 100644 contracts/gauges/gauge/src/helpers.rs create mode 100644 contracts/gauges/gauge/src/lib.rs create mode 100644 contracts/gauges/gauge/src/msg.rs create mode 100644 contracts/gauges/gauge/src/multitest/adapter.rs create mode 100644 contracts/gauges/gauge/src/multitest/gauge.rs create mode 100644 contracts/gauges/gauge/src/multitest/mod.rs create mode 100644 contracts/gauges/gauge/src/multitest/reset.rs create mode 100644 contracts/gauges/gauge/src/multitest/suite.rs create mode 100644 contracts/gauges/gauge/src/multitest/tally.rs create mode 100644 contracts/gauges/gauge/src/multitest/voting.rs create mode 100644 contracts/gauges/gauge/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 486dc1046..10763add3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2547,6 +2547,47 @@ dependencies = [ "slab", ] +[[package]] +name = "gauge-adapter" +version = "2.4.2" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw20 1.1.2", + "cw20-base 1.1.2", + "semver", + "thiserror", +] + +[[package]] +name = "gauge-orchestrator" +version = "2.4.2" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw4 1.1.2", + "cw4-group 1.1.2", + "dao-dao-core", + "dao-hooks", + "dao-interface", + "dao-proposal-single", + "dao-voting 2.4.2", + "dao-voting-cw4", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -3779,9 +3820,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" diff --git a/Cargo.toml b/Cargo.toml index 2f2d2ebe4..4b3718b32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/dao-dao-core", "contracts/distribution/*", "contracts/external/*", + "contracts/gauges/*", "contracts/proposal/*", "contracts/pre-propose/*", "contracts/staking/*", @@ -64,6 +65,7 @@ prost-types = { version = "0.12.3", default-features = false } quote = "1.0" rand = "0.8" schemars = "0.8" +semver = "1.0.23" serde = { version = "1.0", default-features = false, features = ["derive"] } serde-cw-value = "0.7" serde_json = "1.0" @@ -120,6 +122,8 @@ dao-voting-cw4 = { path = "./contracts/voting/dao-voting-cw4", version = "2.4.2" dao-voting-cw721-roles = { path = "./contracts/voting/dao-voting-cw721-roles", version = "2.4.2" } dao-voting-cw721-staked = { path = "./contracts/voting/dao-voting-cw721-staked", version = "2.4.2" } dao-voting-token-staked = { path = "./contracts/voting/dao-voting-token-staked", version = "2.4.2" } +gauge-orchestrator = { path = "./contracts/gauges/gauge", version = "2.4.2" } +gauge-adapter = { path = "./contracts/gauges/gauge-adapter", version = "2.4.2" } # v1 dependencies. used for state migrations. cw-core-v1 = { package = "cw-core", version = "0.1.0" } diff --git a/contracts/gauges/README.md b/contracts/gauges/README.md new file mode 100644 index 000000000..68c610ec7 --- /dev/null +++ b/contracts/gauges/README.md @@ -0,0 +1,3 @@ +# Gauges + +Forked from [Wynd DAO repo](https://github.com/wynddao/wynddao), modified to support any type of DAO. diff --git a/contracts/gauges/gauge-adapter/.cargo/config b/contracts/gauges/gauge-adapter/.cargo/config new file mode 100644 index 000000000..de2d36ac7 --- /dev/null +++ b/contracts/gauges/gauge-adapter/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +wasm-debug = "build --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/gauges/gauge-adapter/Cargo.toml b/contracts/gauges/gauge-adapter/Cargo.toml new file mode 100644 index 000000000..2652827ee --- /dev/null +++ b/contracts/gauges/gauge-adapter/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "gauge-adapter" +authors = ["Jakub "] +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw-utils = { workspace = true } +semver = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cw-multi-test = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } diff --git a/contracts/gauges/gauge-adapter/README.md b/contracts/gauges/gauge-adapter/README.md new file mode 100644 index 000000000..46b14b9bb --- /dev/null +++ b/contracts/gauges/gauge-adapter/README.md @@ -0,0 +1,28 @@ +# Marketing Gauge Adapter Contract + +This is an adapter contract for use in conjunction with the [WYND gauge contract](https://github.com/cosmorama/wynddao/tree/main/contracts/gauge). The purpose of this adapter is to allow people related to marketing to apply for a reward. The total reward amount is set during contract instantiation and will be divided among applicants based on community votes. + +## Implementation + +The basic structure containing all information required for an application is: + +```rust +CreateSubmission { + name: String, + url: String, + address: String, +``` + +Depending on how the gauge contract is instantiated a spam preventing deposit can be required to create a submission. This is specified by the field `required_deposit` in the `Config` structure. + +The contract can receive 3 kind of messages to execute the contract logic: + +1. Create a submission by sending CW20 tokens routed through the CW20 contract. + +2. Create a submission by sending native tokens. + +3. Return all submission's deposit to the address specified during submission. This logic can be triggered only by the contract's `admin` specified during gauge instantiation and saved in the `Config` structure. + +## Options + +Options represent all the addresses that have been stored through the field `address` of the `CreateSubmission` structure. diff --git a/contracts/gauges/gauge-adapter/src/bin/schema.rs b/contracts/gauges/gauge-adapter/src/bin/schema.rs new file mode 100644 index 000000000..e40526000 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/bin/schema.rs @@ -0,0 +1,13 @@ +use cosmwasm_schema::write_api; + +use cosmwasm_std::Empty; +use gauge_adapter::msg::{AdapterQueryMsg, InstantiateMsg, MigrateMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: Empty, + query: AdapterQueryMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/gauges/gauge-adapter/src/contract.rs b/contracts/gauges/gauge-adapter/src/contract.rs new file mode 100644 index 000000000..3f7bc5bc4 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/contract.rs @@ -0,0 +1,519 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + coins, from_json, to_json_binary, Addr, BankMsg, Binary, CosmosMsg, Deps, DepsMut, Env, + MessageInfo, Order, Response, StdError, StdResult, Uint128, WasmMsg, +}; +use cw2::set_contract_version; +use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg}; + +use crate::error::ContractError; +use crate::msg::{AdapterQueryMsg, ExecuteMsg, InstantiateMsg, MigrateMsg, ReceiveMsg}; +use crate::state::{AssetType, Config, Submission, CONFIG, SUBMISSIONS}; + +// Version info for migration info. +const CONTRACT_NAME: &str = "crates.io:marketing-gauge-adapter"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let community_pool = deps.api.addr_validate(&msg.community_pool)?; + SUBMISSIONS.save( + deps.storage, + community_pool.clone(), + &Submission { + sender: env.contract.address, + name: "Unimpressed".to_owned(), + url: "Those funds go back to the community pool".to_owned(), + }, + )?; + + let config = Config { + admin: deps.api.addr_validate(&msg.admin)?, + required_deposit: msg.required_deposit, + community_pool, + reward: msg.reward, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Receive(msg) => receive_cw20_message(deps, info, msg), + ExecuteMsg::CreateSubmission { name, url, address } => { + let received = info.funds.into_iter().next().unwrap_or_default(); + execute::create_submission( + deps, + info.sender, + name, + url, + address, + received.amount, + AssetType::Native(received.denom), + ) + } + ExecuteMsg::ReturnDeposits {} => execute::return_deposits(deps, info.sender), + } +} + +fn receive_cw20_message( + deps: DepsMut, + info: MessageInfo, + msg: Cw20ReceiveMsg, +) -> Result { + match from_json(&msg.msg)? { + ReceiveMsg::CreateSubmission { name, url, address } => { + let denom = AssetType::Cw20(info.sender.to_string()); + execute::create_submission( + deps, + Addr::unchecked(msg.sender), + name, + url, + address, + msg.amount, + denom, + ) + } + } +} + +fn create_bank_msg( + denom: &AssetType, + amount: Uint128, + recipient: Addr, +) -> Result { + match denom { + AssetType::Cw20(address) => Ok(WasmMsg::Execute { + contract_addr: address.to_string(), + msg: to_json_binary(&Cw20ExecuteMsg::Transfer { + recipient: recipient.to_string(), + amount, + })?, + funds: vec![], + } + .into()), + AssetType::Native(denom) => { + let amount = coins(amount.u128(), denom); + Ok(BankMsg::Send { + to_address: recipient.to_string(), + amount, + } + .into()) + } + } +} + +pub mod execute { + use super::*; + + use cosmwasm_std::{ensure_eq, CosmosMsg}; + + pub fn create_submission( + deps: DepsMut, + sender: Addr, + name: String, + url: String, + address: String, + received_amount: Uint128, + received_denom: AssetType, + ) -> Result { + let address = deps.api.addr_validate(&address)?; + + let Config { + required_deposit, + community_pool: _, + reward: _, + admin: _, + } = CONFIG.load(deps.storage)?; + if let Some(required_deposit) = required_deposit { + if AssetType::Native("".into()) == received_denom { + return Err(ContractError::DepositRequired {}); + } + if required_deposit.denom != received_denom { + return Err(ContractError::InvalidDepositType {}); + } + if received_amount != required_deposit.amount { + return Err(ContractError::InvalidDepositAmount { + correct_amount: required_deposit.amount, + }); + } + } else { + // If no deposit is required, then any deposit invalidates a submission. + if !received_amount.is_zero() { + return Err(ContractError::InvalidDepositAmount { + correct_amount: Uint128::zero(), + }); + } + } + + // allow to overwrite submission by the same author + if let Some(old_submission) = SUBMISSIONS.may_load(deps.storage, address.clone())? { + if old_submission.sender != sender { + return Err(ContractError::UnauthorizedSubmission {}); + } + } + + SUBMISSIONS.save(deps.storage, address, &Submission { sender, name, url })?; + Ok(Response::new().add_attribute("create", "submission")) + } + + pub fn return_deposits(deps: DepsMut, sender: Addr) -> Result { + let Config { + admin, + required_deposit, + community_pool: _, + reward: _, + } = CONFIG.load(deps.storage)?; + + // No refund if no deposit was required. + let required_deposit = required_deposit.ok_or(ContractError::NoDepositToRefund {})?; + + ensure_eq!(sender, admin, ContractError::Unauthorized {}); + + let msgs = SUBMISSIONS + .range(deps.storage, None, None, Order::Ascending) + .map(|item| { + let (_submission_recipient, submission) = item?; + create_bank_msg( + &required_deposit.denom, + required_deposit.amount, + submission.sender, + ) + }) + .collect::>>()?; + + Ok(Response::new().add_messages(msgs)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: AdapterQueryMsg) -> StdResult { + match msg { + AdapterQueryMsg::Config {} => to_json_binary(&CONFIG.load(deps.storage)?), + AdapterQueryMsg::AllOptions {} => to_json_binary(&query::all_options(deps)?), + AdapterQueryMsg::CheckOption { option } => { + to_json_binary(&query::check_option(deps, option)?) + } + AdapterQueryMsg::SampleGaugeMsgs { selected } => { + to_json_binary(&query::sample_gauge_msgs(deps, selected)?) + } + AdapterQueryMsg::Submission { address } => { + to_json_binary(&query::submission(deps, address)?) + } + AdapterQueryMsg::AllSubmissions {} => to_json_binary(&query::all_submissions(deps)?), + } +} + +mod query { + use cosmwasm_std::{CosmosMsg, Decimal}; + + use crate::msg::{ + AllOptionsResponse, AllSubmissionsResponse, CheckOptionResponse, SampleGaugeMsgsResponse, + SubmissionResponse, + }; + + use super::*; + + pub fn all_options(deps: Deps) -> StdResult { + Ok(AllOptionsResponse { + options: SUBMISSIONS + .keys(deps.storage, None, None, Order::Ascending) + .map(|key| Ok(key?.to_string())) + .collect::>>()?, + }) + } + + pub fn check_option(deps: Deps, option: String) -> StdResult { + Ok(CheckOptionResponse { + valid: SUBMISSIONS.has(deps.storage, deps.api.addr_validate(&option)?), + }) + } + + pub fn sample_gauge_msgs( + deps: Deps, + winners: Vec<(String, Decimal)>, + ) -> StdResult { + let reward = CONFIG.load(deps.storage)?.reward; + + let execute = winners + .into_iter() + .map(|(to_address, fraction)| { + // Gauge already sents chosen tally to this query by using results we send in + // all_options query; they are already validated + create_bank_msg( + &reward.denom, + fraction * reward.amount, + Addr::unchecked(to_address), + ) + }) + .collect::>>()?; + Ok(SampleGaugeMsgsResponse { execute }) + } + + pub fn submission(deps: Deps, address: String) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let submission = SUBMISSIONS.load(deps.storage, address.clone())?; + Ok(SubmissionResponse { + sender: submission.sender, + name: submission.name, + url: submission.url, + address, + }) + } + + pub fn all_submissions(deps: Deps) -> StdResult { + Ok(AllSubmissionsResponse { + submissions: SUBMISSIONS + .range(deps.storage, None, None, Order::Ascending) + .map(|s| { + let (address, submission) = s?; + Ok(SubmissionResponse { + sender: submission.sender, + name: submission.name, + url: submission.url, + address, + }) + }) + .collect::>>()?, + }) + } +} + +/// Manages the contract migration. +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + Ok(Response::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use cosmwasm_std::{ + testing::{mock_dependencies, mock_env, mock_info}, + Decimal, Uint128, + }; + + use crate::state::Asset; + + #[test] + fn proper_initialization() { + let mut deps = mock_dependencies(); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), + community_pool: "community".to_owned(), + reward: Asset::new_native("ujuno", 150_000_000_000), + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + + // Check if the config is stored. + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.admin, Addr::unchecked("admin")); + assert_eq!( + config.required_deposit, + Some(Asset { + denom: AssetType::Cw20("wynd".to_owned()), + amount: Uint128::new(10_000_000) + }) + ); + assert_eq!(config.community_pool, "community".to_owned()); + assert_eq!( + config.reward, + Asset { + denom: AssetType::Native("ujuno".to_owned()), + amount: Uint128::new(150_000_000_000) + } + ); + + let msg = InstantiateMsg { + reward: Asset::new_native("ujuno", 10_000_000), + ..msg + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!( + config.reward, + Asset { + denom: AssetType::Native("ujuno".to_owned()), + amount: Uint128::new(10_000_000) + } + ); + + let msg = InstantiateMsg { + required_deposit: None, + ..msg + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.required_deposit, None); + } + + #[test] + fn sample_gauge_msgs_native() { + let mut deps = mock_dependencies(); + + let reward = Uint128::new(150_000_000_000); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), + community_pool: "community".to_owned(), + reward: Asset::new_native("ujuno", reward.into()), + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + + let selected = vec![ + ( + "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + Decimal::percent(41), + ), + ( + "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + Decimal::percent(33), + ), + ( + "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + Decimal::percent(26), + ), + ]; + let res = query::sample_gauge_msgs(deps.as_ref(), selected).unwrap(); + assert_eq!(res.execute.len(), 3); + assert_eq!( + res.execute, + [ + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + amount: coins((reward * Decimal::percent(41)).u128(), "ujuno") + }), + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + amount: coins((reward * Decimal::percent(33)).u128(), "ujuno") + }), + CosmosMsg::Bank(BankMsg::Send { + to_address: "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + amount: coins((reward * Decimal::percent(26)).u128(), "ujuno") + }), + ] + ); + } + + #[test] + fn sample_gauge_msgs_cw20() { + let mut deps = mock_dependencies(); + + let reward = Uint128::new(150_000_000_000); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), + community_pool: "community".to_owned(), + reward: Asset::new_cw20("wynd", reward.into()), + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + + let selected = vec![ + ( + "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + Decimal::percent(41), + ), + ( + "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + Decimal::percent(33), + ), + ( + "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + Decimal::percent(26), + ), + ]; + let res = query::sample_gauge_msgs(deps.as_ref(), selected).unwrap(); + assert_eq!(res.execute.len(), 3); + assert_eq!( + res.execute, + [ + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "wynd".to_owned(), + msg: to_json_binary(&Cw20ExecuteMsg::Transfer { + recipient: "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), + amount: reward * Decimal::percent(41) + }) + .unwrap(), + funds: vec![] + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "wynd".to_owned(), + msg: to_json_binary(&Cw20ExecuteMsg::Transfer { + recipient: "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), + amount: reward * Decimal::percent(33) + }) + .unwrap(), + funds: vec![] + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "wynd".to_owned(), + msg: to_json_binary(&Cw20ExecuteMsg::Transfer { + recipient: "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), + amount: reward * Decimal::percent(26) + }) + .unwrap(), + funds: vec![] + }), + ] + ); + } + + #[test] + fn return_deposits_authorization() { + let mut deps = mock_dependencies(); + let msg = InstantiateMsg { + admin: "admin".to_owned(), + required_deposit: None, + community_pool: "community".to_owned(), + reward: Asset::new_native("ujuno", 150_000_000_000), + }; + instantiate( + deps.as_mut(), + mock_env(), + mock_info("user", &[]), + msg.clone(), + ) + .unwrap(); + + let err = execute::return_deposits(deps.as_mut(), Addr::unchecked("user")).unwrap_err(); + assert_eq!(err, ContractError::NoDepositToRefund {}); + + let msg = InstantiateMsg { + required_deposit: Some(Asset::new_native("ujuno", 10_000_000)), + ..msg + }; + instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); + + let err = execute::return_deposits(deps.as_mut(), Addr::unchecked("user")).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + } +} diff --git a/contracts/gauges/gauge-adapter/src/error.rs b/contracts/gauges/gauge-adapter/src/error.rs new file mode 100644 index 000000000..75521219d --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/error.rs @@ -0,0 +1,26 @@ +use cosmwasm_std::{StdError, Uint128}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Operation unauthorized - only admin can release deposits")] + Unauthorized {}, + + #[error("Operation unauthorized - there's already existing submission for that destination address; only previous sender can overwrite it")] + UnauthorizedSubmission {}, + + #[error("Invalid submission - required deposit set in incorrect denom")] + InvalidDepositType {}, + + #[error("Invalid submission - invalid amount for required deposit. Either multiple denoms were sent or amount does not match {correct_amount}")] + InvalidDepositAmount { correct_amount: Uint128 }, + + #[error("No deposit was required, therefore no deposit can be returned")] + NoDepositToRefund {}, + + #[error("Deposit required, cannot create submission.")] + DepositRequired {}, +} diff --git a/contracts/gauges/gauge-adapter/src/lib.rs b/contracts/gauges/gauge-adapter/src/lib.rs new file mode 100644 index 000000000..31d9a5aa4 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod multitest; + +pub use crate::error::ContractError; diff --git a/contracts/gauges/gauge-adapter/src/msg.rs b/contracts/gauges/gauge-adapter/src/msg.rs new file mode 100644 index 000000000..dc1ae2d7c --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/msg.rs @@ -0,0 +1,99 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, CosmosMsg, Decimal}; +use cw20::Cw20ReceiveMsg; + +use crate::state::Asset; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address that is allowed to return deposits. + pub admin: String, + /// Deposit required for valid submission. This option allows to reduce spam. + pub required_deposit: Option, + /// Address of contract where each deposit is transferred. + pub community_pool: String, + /// Total reward amount. + pub reward: Asset, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Implements the Cw20 receiver interface. + Receive(Cw20ReceiveMsg), + /// Save info about team that wants to participate. + /// Only for native tokens as required deposit. + CreateSubmission { + name: String, + url: String, + address: String, + }, + /// Sends back all deposit to senders. + ReturnDeposits {}, +} + +#[cw_serde] +pub enum ReceiveMsg { + /// Save info about team that wants to participate. + /// Only for CW20 tokens as required deposit. + CreateSubmission { + name: String, + url: String, + address: String, + }, +} + +#[cw_serde] +pub enum MigrateMsg {} + +// Queries copied from gauge-orchestrator for now (we could use a common crate for this). +/// Queries the gauge requires from the adapter contract in order to function. +#[cw_serde] +#[derive(QueryResponses)] +pub enum AdapterQueryMsg { + #[returns(crate::state::Config)] + Config {}, + #[returns(AllOptionsResponse)] + AllOptions {}, + #[returns(CheckOptionResponse)] + CheckOption { option: String }, + #[returns(SampleGaugeMsgsResponse)] + SampleGaugeMsgs { + /// Option along with weight. + /// Sum of all weights should be 1.0 (within rounding error). + selected: Vec<(String, Decimal)>, + }, + + // Marketing-gauge specific queries to help on frontend + #[returns(SubmissionResponse)] + Submission { address: String }, + #[returns(AllSubmissionsResponse)] + AllSubmissions {}, +} + +#[cw_serde] +pub struct AllOptionsResponse { + pub options: Vec, +} + +#[cw_serde] +pub struct CheckOptionResponse { + pub valid: bool, +} + +#[cw_serde] +pub struct SampleGaugeMsgsResponse { + pub execute: Vec, +} + +#[cw_serde] +pub struct SubmissionResponse { + pub sender: Addr, + pub name: String, + pub url: String, + pub address: Addr, +} + +#[cw_serde] +pub struct AllSubmissionsResponse { + pub submissions: Vec, +} diff --git a/contracts/gauges/gauge-adapter/src/multitest/mod.rs b/contracts/gauges/gauge-adapter/src/multitest/mod.rs new file mode 100644 index 000000000..e5a2ee02d --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/multitest/mod.rs @@ -0,0 +1,4 @@ +mod suite; + +mod options; +mod submission; diff --git a/contracts/gauges/gauge-adapter/src/multitest/options.rs b/contracts/gauges/gauge-adapter/src/multitest/options.rs new file mode 100644 index 000000000..d1c57e6cf --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/multitest/options.rs @@ -0,0 +1,50 @@ +use cosmwasm_std::{coin, Addr}; + +use crate::multitest::suite::SuiteBuilder; + +#[test] +fn option_queries() { + let mut suite = SuiteBuilder::new() + .with_community_pool("community_pool") + .with_funds("owner", &[coin(100_000, "juno")]) + .with_funds("einstein", &[coin(100_000, "juno")]) + .with_native_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + let options = suite.query_all_options().unwrap(); + // account for a default option + assert_eq!(options.len(), 1); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + // Valid submission. + suite + .execute_create_submission( + Addr::unchecked("einstein"), + "MIBers".to_owned(), + "https://www.mib.tech/".to_owned(), + "einstein".to_owned(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + let options = suite.query_all_options().unwrap(); + assert_eq!(options, vec!["community_pool", "einstein", &recipient],); + + let option = suite.query_check_option("einstein".to_owned()).unwrap(); + assert!(option); + + let option = suite.query_check_option("newton".to_owned()).unwrap(); + assert!(!option); +} diff --git a/contracts/gauges/gauge-adapter/src/multitest/submission.rs b/contracts/gauges/gauge-adapter/src/multitest/submission.rs new file mode 100644 index 000000000..4243ace50 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/multitest/submission.rs @@ -0,0 +1,484 @@ +use crate::{msg::SubmissionResponse, ContractError}; + +use super::suite::SuiteBuilder; + +use cosmwasm_std::{coin, Addr, Uint128}; + +#[test] +fn create_default_submission() { + let suite = SuiteBuilder::new() + .with_community_pool("community_pool") + .build(); + + // this one is created by default during instantiation + assert_eq!( + SubmissionResponse { + sender: suite.gauge_adapter.clone(), + name: "Unimpressed".to_owned(), + url: "Those funds go back to the community pool".to_owned(), + address: Addr::unchecked("community_pool"), + }, + suite.query_submission("community_pool".to_owned()).unwrap() + ) +} + +#[test] +fn create_submission_no_required_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .build(); + + let recipient = "user".to_owned(); + + // Fails send funds along with the tx. + let err = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::zero() + }, + err.downcast().unwrap() + ); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: suite.owner.clone(), + name: "WYNDers".to_owned(), + url: "https://www.wynddao.com/".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + suite.query_submission(recipient).unwrap() + ) +} + +#[test] +fn overwrite_existing_submission() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .build(); + + let recipient = "user".to_owned(); + + suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: suite.owner.clone(), + name: "WYNDers".to_owned(), + url: "https://www.wynddao.com/".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + suite.query_submission(recipient.clone()).unwrap() + ); + + // Try to submit to the same address with different user + let err = suite + .execute_create_submission( + Addr::unchecked("anotheruser"), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap_err(); + assert_eq!( + ContractError::UnauthorizedSubmission {}, + err.downcast().unwrap() + ); + + // Overwriting submission as same author works + let err = suite + .execute_create_submission( + Addr::unchecked("anotheruser"), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap_err(); + assert_eq!( + ContractError::UnauthorizedSubmission {}, + err.downcast().unwrap() + ); + + suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "wynddao".to_owned(), + recipient.clone(), + &[], + ) + .unwrap(); + + let response = suite.query_submission(recipient).unwrap(); + assert_eq!(response.url, "wynddao".to_owned()); +} + +#[test] +fn create_submission_required_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno"), coin(100_000, "wynd")]) + .with_native_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + // Fails if no funds sent. + let err = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[], + ) + .unwrap_err(); + + assert_eq!(ContractError::DepositRequired {}, err.downcast().unwrap()); + + // Fails if correct denom but not enought amount. + let err = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1, "juno")], + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::new(1_000) + }, + err.downcast().unwrap() + ); + + // Fails if enough amount but incorrect denom. + let err = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "wynd")], + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositType {}, + err.downcast().unwrap() + ); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: suite.owner.clone(), + name: "WYNDers".to_owned(), + url: "https://www.wynddao.com/".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + suite.query_submission(recipient).unwrap() + ) +} + +#[test] +fn create_receive_required_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .with_cw20_funds("owner", 1_000) + .with_cw20_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + let cw20_addr = suite.instantiate_token(suite.owner.clone().as_ref(), "moonbites", 1_000_000); + + // Fails by sending wrong cw20. + let err = suite + .execute_receive_through_cw20( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + 1_000, + cw20_addr, + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositType {}, + err.downcast().unwrap(), + ); + + // Fails by sending less tokens than required. + let err = suite + .execute_receive_through_cw20( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + 1, + suite.default_cw20.clone(), + ) + .unwrap_err(); + + assert_eq!( + ContractError::InvalidDepositAmount { + correct_amount: Uint128::new(1_000) + }, + err.downcast().unwrap() + ); + + // Valid submission. + _ = suite + .execute_receive_through_cw20( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + 1_000, + suite.default_cw20.clone(), + ) + .unwrap(); + + assert_eq!( + SubmissionResponse { + sender: suite.owner.clone(), + name: "WYNDers".to_owned(), + url: "https://www.wynddao.com/".to_owned(), + address: Addr::unchecked(recipient.clone()), + }, + suite.query_submission(recipient).unwrap() + ); + + assert_eq!(2, suite.query_submissions().unwrap().len()) +} + +#[test] +fn return_deposits_no_required_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .build(); + + let err = suite + .execute_return_deposit(suite.owner.clone().as_ref()) + .unwrap_err(); + + assert_eq!(ContractError::NoDepositToRefund {}, err.downcast().unwrap()) +} + +#[test] +fn return_deposits_no_admin() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(100_000, "juno")]) + .with_native_deposit(1_000) + .build(); + + let err = suite.execute_return_deposit("einstein").unwrap_err(); + + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()) +} + +#[test] +fn return_deposits_required_native_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(1_000, "juno")]) + .with_native_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + assert_eq!( + suite.query_native_balance(suite.owner.as_ref()).unwrap(), + 0u128, + ); + assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); + assert_eq!( + suite + .query_native_balance(suite.gauge_adapter.as_ref()) + .unwrap(), + 1_000u128, + ); + + _ = suite + .execute_return_deposit(suite.owner.clone().as_ref()) + .unwrap(); + + assert_eq!( + suite.query_native_balance(suite.owner.as_ref()).unwrap(), + 1_000u128, + ); + assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); + assert_eq!( + suite + .query_native_balance(suite.gauge_adapter.as_ref()) + .unwrap(), + 0u128, + ); +} + +#[test] +fn return_deposits_required_native_deposit_multiple_deposits() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(1_000, "juno")]) + .with_funds("einstein", &[coin(1_000, "juno")]) + .with_native_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + // Valid submission. + _ = suite + .execute_create_submission( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + // Valid submission. + _ = suite + .execute_create_submission( + Addr::unchecked("einstein"), + "MIBers".to_owned(), + "https://www.mib.tech/".to_owned(), + "einstein".to_owned(), + &[coin(1_000, "juno")], + ) + .unwrap(); + + _ = suite + .execute_return_deposit(suite.owner.clone().as_ref()) + .unwrap(); + + assert_eq!( + suite.query_native_balance(suite.owner.as_ref()).unwrap(), + 1_000u128, + ); + assert_eq!(suite.query_native_balance("einstein").unwrap(), 1_000u128,); + assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); + assert_eq!( + suite + .query_native_balance(suite.gauge_adapter.as_ref()) + .unwrap(), + 0u128, + ); +} + +#[test] +fn return_deposits_required_cw20_deposit() { + let mut suite = SuiteBuilder::new() + .with_funds("owner", &[coin(1_000, "juno")]) + .with_cw20_funds("owner", 1_000) + .with_cw20_deposit(1_000) + .build(); + + let recipient = "user".to_owned(); + + // Valid submission. + _ = suite + .execute_receive_through_cw20( + suite.owner.clone(), + "WYNDers".to_owned(), + "https://www.wynddao.com/".to_owned(), + recipient.clone(), + 1_000, + suite.default_cw20.clone(), + ) + .unwrap(); + + assert_eq!( + suite + .query_cw20_balance(suite.owner.as_ref(), &suite.default_cw20) + .unwrap(), + 0u128, + ); + assert_eq!( + suite + .query_cw20_balance(&recipient, &suite.default_cw20) + .unwrap(), + 0u128, + ); + assert_eq!( + suite + .query_cw20_balance(suite.gauge_adapter.as_ref(), &suite.default_cw20) + .unwrap(), + 1_000u128, + ); + + _ = suite + .execute_return_deposit(suite.owner.clone().as_ref()) + .unwrap(); + + assert_eq!( + suite + .query_cw20_balance(suite.owner.as_ref(), &suite.default_cw20) + .unwrap(), + 1_000u128, + ); + // Tokens are sent back to the address specified in the sumbission. + assert_eq!( + suite + .query_cw20_balance(&recipient, &suite.default_cw20) + .unwrap(), + 0u128, + ); + assert_eq!( + suite + .query_cw20_balance(suite.gauge_adapter.as_ref(), &suite.default_cw20) + .unwrap(), + 0u128, + ); +} diff --git a/contracts/gauges/gauge-adapter/src/multitest/suite.rs b/contracts/gauges/gauge-adapter/src/multitest/suite.rs new file mode 100644 index 000000000..254868ffb --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/multitest/suite.rs @@ -0,0 +1,332 @@ +use cosmwasm_std::{to_json_binary, Addr, Binary, Coin, Uint128}; +use cw20::{BalanceResponse, Cw20QueryMsg}; +use cw20::{Cw20Coin, MinterResponse}; +use cw_multi_test::{App, AppResponse, ContractWrapper, Executor}; + +use anyhow::Result as AnyResult; +use cw20_base::msg::ExecuteMsg as Cw20BaseExecuteMsg; +use cw20_base::msg::InstantiateMsg as Cw20BaseInstantiateMsg; + +use crate::msg::CheckOptionResponse; +use crate::{ + msg::{ + AdapterQueryMsg, AllOptionsResponse, AllSubmissionsResponse, ExecuteMsg, ReceiveMsg, + SubmissionResponse, + }, + state::{Asset, AssetType}, +}; + +pub const NATIVE: &str = "juno"; +pub const CW20: &str = "wynd"; +pub const OWNER: &str = "owner"; + +// Store the marketing gauge adapter contract and returns the code id. +fn store_gauge_adapter(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + )); + + app.store_code(contract) +} + +// Store the cw20 contract and returns the code id. +fn store_cw20(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + )); + + app.store_code(contract) +} + +#[derive(Debug)] +pub struct SuiteBuilder { + // Gauge adapter's instantiate params + community_pool: String, + required_deposit: Option, + reward: Asset, + funds: Vec<(Addr, Vec)>, + cw20_funds: Vec, +} + +impl SuiteBuilder { + pub fn new() -> Self { + Self { + community_pool: "community".to_owned(), + required_deposit: None, + reward: Asset { + denom: AssetType::Native(NATIVE.into()), + amount: Uint128::new(1_000_000), + }, + funds: vec![], + cw20_funds: vec![], + } + } + + pub fn with_community_pool(mut self, community_pool: &str) -> Self { + self.community_pool = community_pool.to_owned(); + self + } + + // Allows to initialize the suite with native coins associated to an address. + pub fn with_funds(mut self, addr: &str, funds: &[Coin]) -> Self { + self.funds.push((Addr::unchecked(addr), funds.into())); + self + } + + // Allows to initialize the suite with default cw20 tokens associated to an address. + pub fn with_cw20_funds(mut self, addr: &str, amount: u128) -> Self { + self.cw20_funds.push(Cw20Coin { + address: addr.into(), + amount: Uint128::from(amount), + }); + self + } + + // Allows to initialize the marketing gauge adapter with required native coins in the config. + pub fn with_native_deposit(mut self, amount: u128) -> Self { + self.required_deposit = Some(Asset { + denom: AssetType::Native(NATIVE.into()), + amount: Uint128::from(amount), + }); + self + } + + // Allows to initialize the marketing gauge adapter with required cw20 tokens in the config. + pub fn with_cw20_deposit(mut self, amount: u128) -> Self { + self.required_deposit = Some(Asset { + denom: AssetType::Cw20("contract1".to_string()), + amount: Uint128::from(amount), + }); + self + } + + // Instantiate a marketing gauge adapter and returns its address. + fn instantiate_marketing_gauge_adapter( + &self, + app: &mut App, + gauge_id: u64, + owner: &str, + ) -> Addr { + app.instantiate_contract( + gauge_id, + Addr::unchecked(owner), + &crate::msg::InstantiateMsg { + admin: owner.to_owned(), + required_deposit: self.required_deposit.clone(), + community_pool: self.community_pool.clone(), + reward: self.reward.clone(), + }, + &[], + "Marketing Gauge Adapter", + None, + ) + .unwrap() + } + + // Instantiate a cw20 and returns its address. + fn instantiate_default_cw20(&self, app: &mut App, cw20_code_id: u64, owner: &str) -> Addr { + app.instantiate_contract( + cw20_code_id, + Addr::unchecked(owner), + &Cw20BaseInstantiateMsg { + name: CW20.to_owned(), + symbol: CW20.to_owned(), + decimals: 6, + initial_balances: self.cw20_funds.clone(), + mint: Some(MinterResponse { + minter: owner.to_string(), + cap: None, + }), + marketing: None, + }, + &[], + CW20.to_owned(), + None, + ) + .unwrap() + } + + #[track_caller] + pub fn build(self) -> Suite { + let mut app = App::default(); + let owner = Addr::unchecked(OWNER); + + // Store required contracts. + let cw20_code_id = store_cw20(&mut app); + let gauge_adapter_code_id = store_gauge_adapter(&mut app); + + // Instantiate default contracts. + let gauge_adapter_addr = + self.instantiate_marketing_gauge_adapter(&mut app, gauge_adapter_code_id, OWNER); + + // cw20 address must be "contract1" otherwise cannot use `with_cw20_deposit()`. Not very + // elegant but do its job. + let cw20_addr = self.instantiate_default_cw20(&mut app, cw20_code_id, OWNER); + + // Mint initial native token if any. + app.init_modules(|router, _, storage| -> AnyResult<()> { + for (addr, coin) in self.funds { + router.bank.init_balance(storage, &addr, coin)?; + } + Ok(()) + }) + .unwrap(); + + Suite { + owner, + app, + gauge_adapter: gauge_adapter_addr, + default_cw20: cw20_addr, + cw20_code_id, + } + } +} + +pub struct Suite { + pub owner: Addr, + pub app: App, + pub gauge_adapter: Addr, + pub default_cw20: Addr, + // This is stored to instantiate other cw20 tokens in tests. + cw20_code_id: u64, +} + +impl Suite { + // --------------------------------------------------------------------------------------------- + // Execute + // --------------------------------------------------------------------------------------------- + pub fn execute_create_submission( + &mut self, + sender: Addr, + name: String, + url: String, + address: String, + funds: &[Coin], + ) -> AnyResult { + self.app.execute_contract( + sender, + self.gauge_adapter.clone(), + &ExecuteMsg::CreateSubmission { name, url, address }, + funds, + ) + } + + pub fn execute_receive_through_cw20( + &mut self, + sender: Addr, + name: String, + url: String, + address: String, + // amount refers to CW20 amount. + amount: u128, + cw20_addr: Addr, + ) -> AnyResult { + let msg: Binary = to_json_binary(&ReceiveMsg::CreateSubmission { name, url, address })?; + + self.app.execute_contract( + sender, + cw20_addr, + &Cw20BaseExecuteMsg::Send { + contract: self.gauge_adapter.to_string(), + amount: Uint128::from(amount), + msg, + }, + &[], + ) + } + + pub fn execute_return_deposit(&mut self, sender: &str) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + self.gauge_adapter.clone(), + &ExecuteMsg::ReturnDeposits {}, + &[], + ) + } + + // --------------------------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------------------------- + pub fn query_submission(&self, address: String) -> AnyResult { + Ok(self.app.wrap().query_wasm_smart( + self.gauge_adapter.clone(), + &AdapterQueryMsg::Submission { address }, + )?) + } + + pub fn query_submissions(&self) -> AnyResult> { + let res: AllSubmissionsResponse = self.app.wrap().query_wasm_smart( + self.gauge_adapter.clone(), + &AdapterQueryMsg::AllSubmissions {}, + )?; + + Ok(res.submissions) + } + + pub fn query_all_options(&self) -> AnyResult> { + let res: AllOptionsResponse = self + .app + .wrap() + .query_wasm_smart(self.gauge_adapter.clone(), &AdapterQueryMsg::AllOptions {})?; + + Ok(res.options) + } + + pub fn query_check_option(&self, option: String) -> AnyResult { + let res: CheckOptionResponse = self.app.wrap().query_wasm_smart( + self.gauge_adapter.clone(), + &AdapterQueryMsg::CheckOption { option }, + )?; + + Ok(res.valid) + } + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + // Instantiate a cw20 token and assign to address a specific amount. + pub fn instantiate_token(&mut self, address: &str, token: &str, amount: u128) -> Addr { + self.app + .instantiate_contract( + self.cw20_code_id, + Addr::unchecked(address), + &Cw20BaseInstantiateMsg { + name: token.to_owned(), + symbol: token.to_owned(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: address.to_owned(), + amount: Uint128::from(amount), + }], + mint: None, + marketing: None, + }, + &[], + token, + None, + ) + .unwrap() + } + + pub fn query_cw20_balance(&self, user: &str, contract: &Addr) -> AnyResult { + let balance: BalanceResponse = self.app.wrap().query_wasm_smart( + contract, + &Cw20QueryMsg::Balance { + address: user.to_owned(), + }, + )?; + + Ok(balance.balance.into()) + } + + pub fn query_native_balance(&self, user: &str) -> AnyResult { + let balance = self.app.wrap().query_balance(user, NATIVE)?; + + Ok(balance.amount.into()) + } +} diff --git a/contracts/gauges/gauge-adapter/src/state.rs b/contracts/gauges/gauge-adapter/src/state.rs new file mode 100644 index 000000000..e2d836943 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/state.rs @@ -0,0 +1,55 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::{Item, Map}; + +#[cw_serde] +pub struct Config { + /// Address that is allowed to return deposits. + pub admin: Addr, + /// Deposit required for valid submission. + pub required_deposit: Option, + /// Address of contract where each deposit is transferred. + pub community_pool: Addr, + /// Total reward amount. + pub reward: Asset, +} + +pub const CONFIG: Item = Item::new("config"); + +#[cw_serde] +pub enum AssetType { + Native(String), + Cw20(String), +} + +#[cw_serde] +pub struct Asset { + pub denom: AssetType, + pub amount: Uint128, +} + +impl Asset { + pub fn new_native(denom: &str, amount: u128) -> Self { + Self { + denom: AssetType::Native(denom.to_owned()), + amount: amount.into(), + } + } + + pub fn new_cw20(denom: &str, amount: u128) -> Self { + Self { + denom: AssetType::Cw20(denom.to_owned()), + amount: amount.into(), + } + } +} + +#[cw_serde] +pub struct Submission { + pub sender: Addr, + pub name: String, + pub url: String, +} + +// All submissions indexed by submition's fund destination address. +pub const SUBMISSIONS: Map = Map::new("submissions"); diff --git a/contracts/gauges/gauge/.cargo/config b/contracts/gauges/gauge/.cargo/config new file mode 100644 index 000000000..af5698e58 --- /dev/null +++ b/contracts/gauges/gauge/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/gauges/gauge/Cargo.toml b/contracts/gauges/gauge/Cargo.toml new file mode 100644 index 000000000..5b2eb3857 --- /dev/null +++ b/contracts/gauges/gauge/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "gauge-orchestrator" +version = { workspace = true } +authors = ["Cosmorama "] +edition = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +dao-interface = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +cw4 = { workspace = true } +dao-hooks = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +dao-dao-core = { workspace = true } +cw-multi-test = { workspace = true } +dao-proposal-single = { workspace = true } +cw4-group = { workspace = true } +dao-voting-cw4 = { workspace = true } +dao-voting = { workspace = true } diff --git a/contracts/gauges/gauge/README.md b/contracts/gauges/gauge/README.md new file mode 100644 index 000000000..953db48e7 --- /dev/null +++ b/contracts/gauges/gauge/README.md @@ -0,0 +1,92 @@ +# Gauge Orchestrator Contract + +There are many places where we want something like a [gauge contract](https://resources.curve.fi/reward-gauges/gauge-weights), +when we need to select a weighted group out of a larger group of options. For example, we will need one to +select the validators that the staking derivatives delegates to, and relative percentages to which one. +We will need one to select which AMM Pools should receive how many incentives (both JUNO and possibly WYND). +We may need one to assign incentives on our lending protocol. + +## Orchestrator + +To work properly, the gauge must be informed every time that the voting power of a member changes. +It does so by listening to "update hooks" on the underlying staking contract and if an address's +voting power changes, updating their vote weight in the gauge, and the tally for the option they +had voted for (if any). + +Every contract call has some overhead, which is silently added to the basic staking action. +If we have 5 gauges in WYND DAO, we would likely have a minimum of 5 x 65k or 325k gas per staking action, +just to update gauges. This is a lot of overhead, and we want to avoid it. + +To do so, we make one "Gauge Orchestrator", which can manage many different gauges. They all have the +same voting logic and rules to update when the voting power changes. The Orchestrator is the only +contract that must be called by the staking contract, and doing a few writes for each gauge is a +lot cheaper gas-wise than calling a separate contract. + +The Orchestrator has an "owner" (the WYND DAO) which is responsible for adding new gauges here, +and eventually stopping them if we don't need them anymore (to avoid extra writes). + +## Gauge Functionality + +A gauge is initialised with a set of options. Anyone with voting power may vote for any option at any time, +which is recorded, and also updates the tally. If they revote, it checks their last vote to reduce power on +that before adding to the new one. When an "update hook" is triggered, it updates the voting power +of that user's vote, while maintaining the same option. Either increasing or decreasing the tally +for the given option as appropriate. + +Every epoch (eg 1/week), the current tally of the gauge is sampled, and some cut-off applies +(top 20, min 0.5% of votes, etc). The resulting set is the "selected set" and the options along with +their relative vote counts (normalised to 1.0 = total votes within this set) is used to initiate some +action (eg. distribute reward tokens). + +## Extensibility + +We will be using one Orchestrator for many different gauges that update many different contracts. +To make it more extensible, we define option as an arbitrary string that makes sense to that contract. +We also store the integration logic in an external contract, called a `GaugeAdapter` that must provide +3 queries to the Orchestrator: + +* Provide set of all options: maybe expensive, iterate over all and return them. This is used for initialization. +* Check an option: Allow anyone to propose one, and this confirms if it is valid (eg is this a valid address + of a registered AMM pool?) +* Create update messages: Accepts "selected set" as argument, returns `Vec` to be executed by the + gauge contract / DAO. + +### Adapters + +We will create a mock implementation of an Adapter for testing. + +In production, we will need one for that queries an AMM Factory for open pools, +and knows how to send the rewards to the appropriate pools. + +We will need another for a JUNO staking derivative, to select which validators should +be in the set, and then upon execute, inform the contract to delegate to those validators. + +We would need a modified one, that uses IBC packets, for a remote staking derivative. +It would need to query the remote chain for the set of validators. And the messages +would be ICA Packets to transmit the new set to the remote chain. + +As you can see, it should be a quite flexible design, while keeping the tallying logic +centralized here and minimal gas impact on the staking contract to track the multiple gauges.s + +## Example Use + +When the DAO wants to add another gauge, it first uploads the code for generating eg. AMM reward messages, +and instantiates a properly configured Adapter. Then, it votes to create a new Gauge that uses this adapter. +Upon creating the gauge, it will query the adapter for the current set of options to initialize state. + +After one epoch has passed, anyone can trigger `Execute` on this gauge ID, and the Orchestrator will +apply the logic to determine the "selected set". It will then query the adapter for the messages +needed to convert that selection into the appropriate action, and it will send those to the +[WYND DAO core module](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#the-core-module) +to be executed. + +## Storage + +Every gauge that is created is given a new auto-incrementing ID. + +All non-global state in the contract (only owner and voting power contract) is indexed +first by the gauge and then by the other key (eg. voter address for Votes, option for tallied power, etc) + +We do not know how many gauges will be there a priori and this composite index allows us to +be flexible. Not the use of `.prefix()` and `.sub_prefix()` in `state.rs` tests to efficiently +focus on the relevant data for one gauge. diff --git a/contracts/gauges/gauge/src/bin/schema.rs b/contracts/gauges/gauge/src/bin/schema.rs new file mode 100644 index 000000000..282b761ee --- /dev/null +++ b/contracts/gauges/gauge/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; + +use gauge_orchestrator::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + } +} diff --git a/contracts/gauges/gauge/src/contract.rs b/contracts/gauges/gauge/src/contract.rs new file mode 100644 index 000000000..0977716d3 --- /dev/null +++ b/contracts/gauges/gauge/src/contract.rs @@ -0,0 +1,840 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + ensure, to_json_binary, Addr, Binary, Decimal, Deps, DepsMut, Env, MessageInfo, Order, + QueryRequest, Response, StdError, StdResult, Uint128, WasmMsg, WasmQuery, +}; +use cw2::{ensure_from_older_version, set_contract_version}; +use cw_storage_plus::Bound; +use dao_interface::{ + msg::ExecuteMsg as DaoExecuteMsg, + voting::{Query as DaoQuery, VotingPowerAtHeightResponse}, +}; + +use crate::msg::{ + AdapterQueryMsg, AllOptionsResponse, CheckOptionResponse, ExecuteMsg, GaugeConfig, + GaugeResponse, InstantiateMsg, ListGaugesResponse, ListOptionsResponse, ListVotesResponse, + MigrateMsg, QueryMsg, SampleGaugeMsgsResponse, SelectedSetResponse, +}; +use crate::state::{ + fetch_last_id, update_tally, votes, Config, Gauge, GaugeId, CONFIG, GAUGES, OPTION_BY_POINTS, + TALLY, TOTAL_CAST, +}; +use crate::{error::ContractError, state::Reset}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:gauge"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let voting_powers = deps.api.addr_validate(&msg.voting_powers)?; + let owner = deps.api.addr_validate(&msg.owner)?; + let config = Config { + voting_powers, + owner, + dao_core: info.sender, + }; + CONFIG.save(deps.storage, &config)?; + + for gauge in msg.gauges.unwrap_or_default() { + execute::attach_gauge(deps.branch(), env.clone(), gauge)?; + } + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("owner", &msg.owner) + .add_attribute("voting_powers", &msg.voting_powers)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::MemberChangedHook(hook_msg) => { + execute::member_changed(deps, info.sender, hook_msg.diffs) + } + ExecuteMsg::CreateGauge(options) => execute::create_gauge(deps, env, info.sender, options), + ExecuteMsg::UpdateGauge { + gauge_id, + epoch_size, + min_percent_selected, + max_options_selected, + max_available_percentage, + } => execute::update_gauge( + deps, + info.sender, + gauge_id, + epoch_size, + min_percent_selected, + max_options_selected, + max_available_percentage, + ), + ExecuteMsg::StopGauge { gauge } => execute::stop_gauge(deps, info.sender, gauge), + ExecuteMsg::ResetGauge { gauge, batch_size } => { + execute::reset_gauge(deps, env, gauge, batch_size) + } + ExecuteMsg::AddOption { gauge, option } => { + execute::add_option(deps, info.sender, gauge, option, true) + } + ExecuteMsg::RemoveOption { gauge, option } => { + execute::remove_option(deps, info.sender, gauge, option) + } + ExecuteMsg::PlaceVotes { gauge, votes } => { + execute::place_votes(deps, env, info.sender, gauge, votes) + } + ExecuteMsg::Execute { gauge } => execute::execute(deps, env, gauge), + } +} + +mod execute { + use cw4::MemberDiff; + + use super::*; + use crate::state::{remove_tally, update_tallies, Reset, Vote}; + use std::collections::HashMap; + + pub fn member_changed( + deps: DepsMut, + sender: Addr, + diffs: Vec, + ) -> Result { + // make sure only voting powers contract can activate this endpoint + if sender != CONFIG.load(deps.storage)?.voting_powers { + return Err(ContractError::Unauthorized {}); + } + + let mut response = Response::new().add_attribute("action", "member_changed_hook"); + let mut gauges = HashMap::new(); + + for diff in diffs { + response = response.add_attribute("member", &diff.key); + let voter = deps.api.addr_validate(&diff.key)?; + + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in + votes().query_votes_by_voter(deps.as_ref(), &voter, None, Some(query::MAX_LIMIT))? + { + // find change of vote powers + let old = Uint128::new(diff.old.unwrap_or_default().into()); + let new = Uint128::new(diff.new.unwrap_or_default().into()); + + // load gauge if not already loaded + let gauge = gauges + .entry(vote.gauge_id) + .or_insert_with(|| GAUGES.load(deps.storage, vote.gauge_id).unwrap()); + + if vote.is_expired(gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // store new vote power for this user + vote.power = new; + votes().save(deps.storage, &voter, vote.gauge_id, &vote)?; + } + } + + Ok(response) + } + + pub fn create_gauge( + deps: DepsMut, + env: Env, + sender: Addr, + options: GaugeConfig, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let adapter = attach_gauge(deps, env, options)?; + + Ok(Response::new() + .add_attribute("action", "create_gauge") + .add_attribute("adapter", adapter)) + } + + pub fn attach_gauge( + mut deps: DepsMut, + env: Env, + GaugeConfig { + title, + adapter, + epoch_size, + min_percent_selected, + max_options_selected, + max_available_percentage, + reset_epoch, + }: GaugeConfig, + ) -> Result { + let adapter = deps.api.addr_validate(&adapter)?; + // gauge parameter validation + ensure!(epoch_size > 60u64, ContractError::EpochSizeTooShort {}); + if let Some(min_percent_selected) = min_percent_selected { + ensure!( + min_percent_selected < Decimal::one(), + ContractError::MinPercentSelectedTooBig {} + ); + } + ensure!( + max_options_selected > 0, + ContractError::MaxOptionsSelectedTooSmall {} + ); + let gauge = Gauge { + title, + adapter: adapter.clone(), + epoch: epoch_size, + min_percent_selected, + max_options_selected, + max_available_percentage, + is_stopped: false, + next_epoch: env.block.time.seconds() + epoch_size, + last_executed_set: None, + reset: reset_epoch.map(|r| Reset { + last: None, + reset_each: r, + next: env.block.time.plus_seconds(r).seconds(), + }), + }; + let last_id: GaugeId = fetch_last_id(deps.storage)?; + GAUGES.save(deps.storage, last_id, &gauge)?; + + // fetch adapter options + let adapter_options: AllOptionsResponse = + deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: adapter.to_string(), + msg: to_json_binary(&AdapterQueryMsg::AllOptions {})?, + }))?; + adapter_options.options.into_iter().try_for_each(|option| { + execute::add_option(deps.branch(), adapter.clone(), last_id, option, false)?; + Ok::<_, ContractError>(()) + })?; + + Ok(adapter) + } + + pub fn update_gauge( + deps: DepsMut, + sender: Addr, + gauge_id: u64, + epoch_size: Option, + min_percent_selected: Option, + max_options_selected: Option, + max_available_percentage: Option, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; + if let Some(epoch_size) = epoch_size { + ensure!(epoch_size > 60u64, ContractError::EpochSizeTooShort {}); + gauge.epoch = epoch_size; + } + if let Some(min_percent_selected) = min_percent_selected { + if min_percent_selected.is_zero() { + gauge.min_percent_selected = None + } else { + ensure!( + min_percent_selected < Decimal::one(), + ContractError::MinPercentSelectedTooBig {} + ); + gauge.min_percent_selected = Some(min_percent_selected) + }; + } + if let Some(max_options_selected) = max_options_selected { + ensure!( + max_options_selected > 0, + ContractError::MaxOptionsSelectedTooSmall {} + ); + gauge.max_options_selected = max_options_selected; + } + if let Some(max_available_percentage) = max_available_percentage { + if max_available_percentage.is_zero() { + gauge.max_available_percentage = None + } else { + ensure!( + max_available_percentage < Decimal::one(), + ContractError::MaxAvailablePercentTooBig {} + ); + gauge.max_available_percentage = Some(max_available_percentage) + }; + } + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new().add_attribute("action", "update_gauge")) + } + + pub fn stop_gauge( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + ) -> Result { + let config = CONFIG.load(deps.storage)?; + if sender != config.owner { + return Err(ContractError::Unauthorized {}); + } + + let gauge = GAUGES.load(deps.storage, gauge_id)?; + let gauge = Gauge { + is_stopped: true, + ..gauge + }; + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new() + .add_attribute("action", "stop_gauge") + .add_attribute("gauge_id", gauge_id.to_string())) + } + + pub fn remove_option( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + option: String, + ) -> Result { + // check if such option even exists + if !TALLY.has(deps.as_ref().storage, (gauge_id, &option)) { + return Err(ContractError::OptionDoesNotExists { option, gauge_id }); + }; + + // only owner can remove option for now + if sender != CONFIG.load(deps.storage)?.owner { + return Err(ContractError::Unauthorized {}); + } + + remove_tally(deps.storage, gauge_id, &option)?; + + Ok(Response::new() + .add_attribute("action", "remove_option") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()) + .add_attribute("option", option)) + } + + pub fn reset_gauge( + deps: DepsMut, + env: Env, + gauge_id: GaugeId, + batch_size: u32, + ) -> Result { + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; + match gauge.reset { + Some(ref mut reset) if reset.next <= env.block.time.seconds() => { + reset.last = Some(reset.next); + + // remove all options from the gauge + let keys = OPTION_BY_POINTS + .sub_prefix(gauge_id) + .keys(deps.storage, None, None, Order::Ascending) + .take(batch_size as usize) + .collect::>>()?; + for (points, option) in &keys { + OPTION_BY_POINTS.remove(deps.storage, (gauge_id, *points, option)); + OPTION_BY_POINTS.save(deps.storage, (gauge_id, 0, option), &1)?; + TALLY.save(deps.storage, (gauge_id, option), &0)?; + } + + // if this is the last batch, update the reset epoch + if (keys.len() as u32) < batch_size { + // removing total cast only once at the end to save gas + TOTAL_CAST.save(deps.storage, gauge_id, &0)?; + reset.next += reset.reset_each; + } + } + Some(_) => { + return Err(ContractError::ResetEpochNotPassed {}); + } + None => { + return Err(ContractError::Unauthorized {}); + } + } + + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new() + .add_attribute("action", "reset_gauge") + .add_attribute("gauge_id", gauge_id.to_string())) + } + + pub fn add_option( + deps: DepsMut, + sender: Addr, + gauge_id: GaugeId, + option: String, + // must be true if option is added by execute message + check_option: bool, + ) -> Result { + // check is such option already exists + if TALLY.has(deps.as_ref().storage, (gauge_id, &option)) { + return Err(ContractError::OptionAlreadyExists { option, gauge_id }); + }; + + // only options added from gauge creation level should not be validated and can + // have 0 points as assigned voting power. + if check_option { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + // query gauge adapter if it is valid + let adapter_option: CheckOptionResponse = deps + .querier + .query_wasm_smart( + gauge.adapter, + &AdapterQueryMsg::CheckOption { + option: option.clone(), + }, + ) + .map_err(|_| ContractError::OptionInvalidByAdapter { + option: option.clone(), + gauge_id, + })?; + if !adapter_option.valid { + return Err(ContractError::OptionInvalidByAdapter { option, gauge_id }); + } + // If it is a user adding option, query him for voting power in order to prevent + // spam from nonvoting users + let voting_power = deps + .querier + .query::(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: CONFIG.load(deps.storage)?.voting_powers.to_string(), + msg: to_json_binary(&DaoQuery::VotingPowerAtHeight { + address: sender.to_string(), + height: None, + })?, + }))? + .power; + if voting_power.is_zero() { + return Err(ContractError::NoVotingPower(sender.to_string())); + } + } + + update_tally(deps.storage, gauge_id, &option, 0u128, 0u128)?; + + Ok(Response::new() + .add_attribute("action", "add_option") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()) + .add_attribute("option", option)) + } + + pub fn place_votes( + deps: DepsMut, + env: Env, + sender: Addr, + gauge_id: GaugeId, + new_votes: Option>, + ) -> Result { + let gauge = match GAUGES.may_load(deps.storage, gauge_id)? { + Some(gauge) => gauge, + None => return Err(ContractError::GaugeMissing(gauge_id)), + }; + + if gauge.is_resetting() { + return Err(ContractError::GaugeResetting(gauge_id)); + } + + // make sure sums work out + let new_votes = new_votes.unwrap_or_default(); + let total_weight = new_votes.iter().map(|v| v.weight).sum(); + if total_weight > Decimal::one() { + return Err(ContractError::TooMuchVotingWeight(total_weight)); + } + + // load voter power from voting powers contract (DAO) + let voting_power = deps + .querier + .query::(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: CONFIG.load(deps.storage)?.voting_powers.to_string(), + msg: to_json_binary(&DaoQuery::VotingPowerAtHeight { + address: sender.to_string(), + height: None, + })?, + }))? + .power; + if voting_power.is_zero() { + return Err(ContractError::NoVotingPower(sender.to_string())); + } + + let mut previous_vote = votes().may_load(deps.storage, &sender, gauge_id)?; + if let Some(v) = &previous_vote { + if v.is_expired(&gauge) { + previous_vote = None; + } + } + if previous_vote.is_none() && new_votes.is_empty() { + return Err(ContractError::CannotRemoveNonexistingVote {}); + } + + // first, calculate a diff between new_vote and previous_vote (option -> (old, new)) + let previous_vote = previous_vote.unwrap_or_default(); + let power = previous_vote.power; + let mut diff: HashMap<&str, (u128, u128)> = previous_vote + .votes + .iter() + .map(|v| (v.option.as_str(), ((power * v.weight).u128(), 0u128))) + .collect(); + for v in new_votes.iter() { + let new = (voting_power * v.weight).u128(); + let add = match diff.remove(v.option.as_str()) { + Some((old, _)) => (old, new), + None => (0, new), + }; + diff.insert(&v.option, add); + } + + // second, test any new options are valid, + // only for those voted for first time (others have already been checked) + for new_opt in diff + .iter() + .filter(|(_, (old, _))| *old == 0) + .map(|(&k, _)| k) + { + if !TALLY.has(deps.storage, (gauge_id, new_opt)) { + return Err(ContractError::OptionDoesNotExists { + option: new_opt.to_string(), + gauge_id, + }); + } + } + + // third, update tally based on diff + let updates: Vec<(&str, u128, u128)> = diff + .iter() + .map(|(&k, (old, new))| (k, *old, *new)) + .collect(); + update_tallies(deps.storage, gauge_id, updates)?; + + // finally, update the votes for this user + if new_votes.is_empty() { + // completely remove sender's votes + votes().remove_votes(deps.storage, &sender, gauge_id)?; + } else { + // store sender's new votes (overwriting old votes) + votes().set_votes( + deps.storage, + &env, + &sender, + gauge_id, + new_votes, + voting_power, + )?; + } + + let response = Response::new() + .add_attribute("action", "place_vote") + .add_attribute("sender", &sender) + .add_attribute("gauge_id", gauge_id.to_string()); + Ok(response) + } + + pub fn execute(deps: DepsMut, env: Env, gauge_id: u64) -> Result { + let mut gauge = GAUGES.load(deps.storage, gauge_id)?; + + if gauge.is_stopped { + return Err(ContractError::GaugeStopped(gauge_id)); + } + if gauge.is_resetting() { + return Err(ContractError::GaugeResetting(gauge_id)); + } + + let current_epoch = env.block.time.seconds(); + if current_epoch < gauge.next_epoch { + return Err(ContractError::EpochNotReached { + gauge_id, + current_epoch, + next_epoch: gauge.next_epoch, + }); + } + gauge.next_epoch = env.block.time.plus_seconds(gauge.epoch).seconds(); + + // this set contains tuple (option, total_voted_power) + // for adapter query, this needs to be transformed into (option, voted_weight) + let selected_set_with_powers = query::selected_set(deps.as_ref(), gauge_id)?.votes; + let selected_powers_sum = selected_set_with_powers + .iter() + .map(|(_, power)| power.u128()) + .sum::(); + + // save the selected options and their powers for the frontend to display + gauge.last_executed_set = Some(selected_set_with_powers.clone()); + + // calculate "local" ratios of voted options per total power of all selected options + let selected = selected_set_with_powers + .into_iter() + .map(|(option, power)| Ok((option, Decimal::from_ratio(power, selected_powers_sum)))) + .collect::>>()?; + + // query gauge adapter for execute messages for DAO + let execute_messages: SampleGaugeMsgsResponse = deps.querier.query_wasm_smart( + gauge.adapter.clone(), + &AdapterQueryMsg::SampleGaugeMsgs { selected }, + )?; + + let config = CONFIG.load(deps.storage)?; + let execute_msg = WasmMsg::Execute { + contract_addr: config.dao_core.to_string(), + msg: to_json_binary(&DaoExecuteMsg::ExecuteProposalHook { + msgs: execute_messages.execute, + })?, + funds: vec![], + }; + + GAUGES.save(deps.storage, gauge_id, &gauge)?; + + Ok(Response::new() + .add_attribute("action", "execute_tally") + .add_message(execute_msg)) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Info {} => Ok(to_json_binary(&query::info(deps)?)?), + QueryMsg::Gauge { id } => Ok(to_json_binary(&query::gauge(deps, id)?)?), + QueryMsg::ListGauges { start_after, limit } => Ok(to_json_binary(&query::list_gauges( + deps, + start_after, + limit, + )?)?), + QueryMsg::Vote { gauge, voter } => Ok(to_json_binary(&query::vote(deps, gauge, voter)?)?), + QueryMsg::ListVotes { + gauge, + start_after, + limit, + } => Ok(to_json_binary(&query::list_votes( + deps, + gauge, + start_after, + limit, + )?)?), + QueryMsg::ListOptions { + gauge, + start_after, + limit, + } => Ok(to_json_binary(&query::list_options( + deps, + gauge, + start_after, + limit, + )?)?), + QueryMsg::SelectedSet { gauge } => Ok(to_json_binary(&query::selected_set(deps, gauge)?)?), + QueryMsg::LastExecutedSet { gauge } => { + Ok(to_json_binary(&query::last_executed_set(deps, gauge)?)?) + } + } +} + +mod query { + use super::*; + + use crate::msg::{LastExecutedSetResponse, VoteInfo, VoteResponse}; + use dao_interface::voting::InfoResponse; + + pub fn info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + Ok(InfoResponse { info }) + } + + fn to_gauge_response(gauge_id: GaugeId, gauge: Gauge) -> GaugeResponse { + GaugeResponse { + id: gauge_id, + title: gauge.title, + adapter: gauge.adapter.to_string(), + epoch_size: gauge.epoch, + min_percent_selected: gauge.min_percent_selected, + max_options_selected: gauge.max_options_selected, + max_available_percentage: gauge.max_available_percentage, + is_stopped: gauge.is_stopped, + next_epoch: gauge.next_epoch, + reset: gauge.reset, + } + } + + pub fn gauge(deps: Deps, gauge_id: GaugeId) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + Ok(to_gauge_response(gauge_id, gauge)) + } + + // settings for pagination + pub const MAX_LIMIT: u32 = 100; + pub const DEFAULT_LIMIT: u32 = 30; + + pub fn list_gauges( + deps: Deps, + start_after: Option, + limit: Option, + ) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + + Ok(ListGaugesResponse { + gauges: GAUGES + .range(deps.storage, start, None, Order::Ascending) + .map(|item| { + let (id, gauge) = item?; + Ok(to_gauge_response(id, gauge)) + }) + .take(limit) + .collect::>>()?, + }) + } + + pub fn vote(deps: Deps, gauge_id: u64, voter: String) -> StdResult { + let voter_addr = deps.api.addr_validate(&voter)?; + let gauge = GAUGES.load(deps.storage, gauge_id)?; + + let vote = votes() + .may_load(deps.storage, &voter_addr, gauge_id)? + .filter(|v| !v.is_expired(&gauge)) + .map(|v| VoteInfo { + voter, + votes: v.votes, + cast: v.cast, + }); + Ok(VoteResponse { vote }) + } + + pub fn list_votes( + deps: Deps, + gauge_id: u64, + start_after: Option, + limit: Option, + ) -> StdResult { + Ok(ListVotesResponse { + votes: votes().query_votes_by_gauge(deps, gauge_id, start_after, limit)?, + }) + } + + pub fn list_options( + deps: Deps, + gauge_id: u64, + start_after: Option, + limit: Option, + ) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start_after = start_after.as_ref().map(|s| Bound::exclusive(s.as_str())); + + Ok(ListOptionsResponse { + options: TALLY + .prefix(gauge_id) + .range(deps.storage, start_after, None, Order::Ascending) + .map(|option| { + let (option, power) = option?; + Ok((option, Uint128::new(power))) + }) + .take(limit) + .collect::>>()?, + }) + } + + pub fn selected_set(deps: Deps, gauge_id: u64) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + let total_cast = TOTAL_CAST.load(deps.storage, gauge_id)?; + + if gauge.is_resetting() || total_cast == 0 { + return Ok(SelectedSetResponse { votes: vec![] }); + } + + // This is sorted index, but requires manual filtering - cannot be prefixed + // given our requirements + let votes = OPTION_BY_POINTS + .sub_prefix(gauge_id) + .range(deps.storage, None, None, Order::Descending) + .filter(|o| { + let ((power, _), _) = o.as_ref().unwrap(); + if let Some(min_percent_selected) = gauge.min_percent_selected { + Decimal::from_ratio(*power, total_cast) >= min_percent_selected + } else { + // filter out options without a vote + *power != 0u128 + } + }) + .map(|o| { + let ((power, option), _) = o?; + // If gauge has max_available_percentage set, discard all power + // above that percentage + if let Some(max_available_percentage) = gauge.max_available_percentage { + if Decimal::from_ratio(power, total_cast) > max_available_percentage { + // If power is above available percentage, cut power down to max available + return Ok((option, Uint128::new(total_cast) * max_available_percentage)); + } + } + Ok((option, Uint128::new(power))) + }) + .take(gauge.max_options_selected as usize) + .collect::>>()?; + + Ok(SelectedSetResponse { votes }) + } + + pub fn last_executed_set(deps: Deps, gauge_id: u64) -> StdResult { + let gauge = GAUGES.load(deps.storage, gauge_id)?; + Ok(LastExecutedSetResponse { + votes: gauge.last_executed_set, + }) + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, env: Env, msg: MigrateMsg) -> Result { + ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + for (gauge_id, config) in msg.gauge_config.unwrap_or_default() { + GAUGES.update(deps.storage, gauge_id, |gauge| -> StdResult<_> { + let mut gauge = gauge.ok_or(StdError::NotFound { + kind: format!("Gauge with id {}", gauge_id), + })?; + if let Some(next_epoch) = config.next_epoch { + if next_epoch < env.block.time.seconds() { + return Err(StdError::GenericErr { + msg: "Next epoch value cannot be earlier then current epoch!".to_owned(), + }); + } + gauge.next_epoch = next_epoch; + } + if let Some(reset_config) = config.reset { + if reset_config.next_reset < env.block.time.seconds() { + return Err(StdError::GenericErr { + msg: "Next reset value cannot be earlier then current epoch!".to_owned(), + }); + } + gauge.reset = Some(Reset { + last: gauge.reset.map(|r| r.last).unwrap_or_default(), + reset_each: reset_config.reset_epoch, + next: reset_config.next_reset, + }); + } + Ok(gauge) + })?; + } + + Ok(Response::new()) +} diff --git a/contracts/gauges/gauge/src/error.rs b/contracts/gauges/gauge/src/error.rs new file mode 100644 index 000000000..c320a4ec7 --- /dev/null +++ b/contracts/gauges/gauge/src/error.rs @@ -0,0 +1,63 @@ +use cosmwasm_std::{Decimal, StdError}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Gauge with ID {0} does not exists")] + GaugeMissing(u64), + + #[error("Voted for {0} times total voting power. Limit 1.0")] + TooMuchVotingWeight(Decimal), + + #[error("User {0} has no voting power")] + NoVotingPower(String), + + #[error("Option {option} already exists for gauge ID {gauge_id}")] + OptionAlreadyExists { option: String, gauge_id: u64 }, + + #[error("Option {option} has been judged as invalid by gauge adapter of gauge ID {gauge_id}")] + OptionInvalidByAdapter { option: String, gauge_id: u64 }, + + #[error("Option {option} has been judged as valid by gauge adapter of gauge ID {gauge_id} and cannot be removed")] + OptionValidByAdapter { option: String, gauge_id: u64 }, + + #[error("Option {option} does not exists for gauge ID {gauge_id}")] + OptionDoesNotExists { option: String, gauge_id: u64 }, + + #[error("Gauge ID {gauge_id} cannot execute because next_epoch is not yet reached: current {current_epoch}, next_epoch: {next_epoch}")] + EpochNotReached { + gauge_id: u64, + current_epoch: u64, + next_epoch: u64, + }, + + #[error("Reset epoch has not passed yet")] + ResetEpochNotPassed {}, + + #[error("Gauge ID {0} cannot execute because it is stopped")] + GaugeStopped(u64), + + #[error("Gauge ID {0} is currently resetting, please try again later")] + GaugeResetting(u64), + + #[error("Trying to remove vote that does not exists")] + CannotRemoveNonexistingVote {}, + + #[error("Epoch size must be bigger then 60 seconds")] + EpochSizeTooShort {}, + + #[error("Minimum percent selected parameter needs to be smaller then 1.0")] + MinPercentSelectedTooBig {}, + + #[error("Maximum options selected parameter needs to be bigger then 0")] + MaxOptionsSelectedTooSmall {}, + + #[error("Maximum percentage available parameter needs to be smaller then 1.0")] + MaxAvailablePercentTooBig {}, +} diff --git a/contracts/gauges/gauge/src/helpers.rs b/contracts/gauges/gauge/src/helpers.rs new file mode 100644 index 000000000..6ffc885df --- /dev/null +++ b/contracts/gauges/gauge/src/helpers.rs @@ -0,0 +1,27 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, StdResult, WasmMsg}; + +use crate::msg::ExecuteMsg; + +/// CwTemplateContract is a wrapper around Addr that provides a lot of helpers +/// for working with this. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct CwTemplateContract(pub Addr); + +impl CwTemplateContract { + pub fn addr(&self) -> Addr { + self.0.clone() + } + + pub fn call>(&self, msg: T) -> StdResult { + let msg = to_json_binary(&msg.into())?; + Ok(WasmMsg::Execute { + contract_addr: self.addr().into(), + msg, + funds: vec![], + } + .into()) + } +} diff --git a/contracts/gauges/gauge/src/lib.rs b/contracts/gauges/gauge/src/lib.rs new file mode 100644 index 000000000..8e1682396 --- /dev/null +++ b/contracts/gauges/gauge/src/lib.rs @@ -0,0 +1,9 @@ +pub mod contract; +mod error; +pub mod helpers; +pub mod msg; +#[cfg(test)] +mod multitest; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/gauges/gauge/src/msg.rs b/contracts/gauges/gauge/src/msg.rs new file mode 100644 index 000000000..468590361 --- /dev/null +++ b/contracts/gauges/gauge/src/msg.rs @@ -0,0 +1,259 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{CosmosMsg, Decimal, Uint128}; +use cw4::MemberChangedHookMsg; + +use crate::state::{Reset, Vote}; +// use wynd_stake::hook::MemberChangedHookMsg; + +type GaugeId = u64; + +#[cw_serde] +pub struct InstantiateMsg { + /// Address of contract to that contains all voting powers (where we query and listen to hooks) + pub voting_powers: String, + /// Address that can add new gauges or stop them + pub owner: String, + /// Allow attaching multiple adaptors during instantiation. + /// Important, as instantiation and CreateGauge both come from DAO proposals + /// and without this argument, you need 2 cycles to create and configure a gauge + pub gauges: Option>, +} + +#[cw_serde] +pub struct GaugeConfig { + /// Name of the gauge (for UI) + pub title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: String, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch_size: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set. + /// If unset, there is no minimum percentage, just the `max_options_selected` limit. + pub min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, + /// If set, the gauge can be reset periodically, every `reset_epoch` seconds. + pub reset_epoch: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Must be compatible with MemberChangedExecuteMsg from wynd-stake. + /// Use this to update + /// TODO fix me! + MemberChangedHook(MemberChangedHookMsg), + /// This creates a new Gauge, returns CreateGaugeReply JSON-encoded in the data field. + /// Can only be called by owner + CreateGauge(GaugeConfig), + /// Allows owner to update certain parameters of GaugeConfig. + /// If you want to change next_epoch value, you need to use migration. + UpdateGauge { + gauge_id: u64, + epoch_size: Option, + // Some<0> would set min_percent_selected to None + min_percent_selected: Option, + max_options_selected: Option, + max_available_percentage: Option, + }, + /// Stops a given gauge, meaning it will not execute any more messages, + /// Or receive any more updates on MemberChangedHook. + /// Ideally, this will allow for eventual deletion of all data on that gauge + StopGauge { gauge: u64 }, + /// Resets all votes on a given gauge if it is configured to be periodically reset and the epoch has passed. + /// One call to this will only clear `batch_size` votes to prevent gas exhaustion. Call repeatedly to clear all votes. + ResetGauge { gauge: u64, batch_size: u32 }, + // WISH: make this implicit - call it inside PlaceVote. + // If not, I would just make it invisible to user in UI (smart client adds it if needed) + /// Try to add an option. Error if no such gauge, or option already registered. + /// Otherwise check adapter and error if invalid. + /// Can be called by anyone, not just owner + AddOption { gauge: u64, option: String }, + /// Allows the owner to remove an option. This is useful if the option is no longer valid + /// or if the owner wants to remove all votes from a valid option. + RemoveOption { gauge: u64, option: String }, + /// Place your vote on the gauge. Can be updated anytime + PlaceVotes { + /// Gauge to vote on + gauge: u64, + /// The options to put my vote on, along with proper weights (must sum up to 1.0) + /// "None" means remove existing votes and abstain + votes: Option>, + }, + /// Takes a sample of the current tally and execute the proper messages to make it work + Execute { gauge: u64 }, +} + +#[cw_serde] +pub struct CreateGaugeReply { + /// Id of the gauge that was just created + pub id: u64, +} + +/// Queries the gauge exposes +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(dao_interface::voting::InfoResponse)] + Info {}, + #[returns(GaugeResponse)] + Gauge { id: u64 }, + #[returns(ListGaugesResponse)] + ListGauges { + start_after: Option, + limit: Option, + }, + #[returns(VoteResponse)] + Vote { gauge: u64, voter: String }, + #[returns(ListVotesResponse)] + ListVotes { + gauge: u64, + start_after: Option, + limit: Option, + }, + #[returns(ListOptionsResponse)] + ListOptions { + gauge: u64, + start_after: Option, + limit: Option, + }, + #[returns(SelectedSetResponse)] + SelectedSet { gauge: u64 }, + #[returns(LastExecutedSetResponse)] + LastExecutedSet { gauge: u64 }, +} + +/// Information about one gauge +#[cw_serde] +pub struct GaugeResponse { + pub id: u64, + /// Name of the gauge (for UI) + pub title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: String, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch_size: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set. + /// If unset, there is no minimum percentage, just the `max_options_selected` limit. + pub min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, + /// True if the gauge is stopped + pub is_stopped: bool, + /// UNIX time (seconds) when next epoch may be executed. May be future or past + pub next_epoch: u64, + /// Set this in migration if the gauge should be periodically reset + pub reset: Option, +} + +/// Information about one gauge +#[cw_serde] +pub struct ListGaugesResponse { + pub gauges: Vec, +} + +/// Information about a vote that was cast. +#[cw_serde] +pub struct VoteInfo { + /// The address that voted. + pub voter: String, + /// List of all votes with power + pub votes: Vec, + /// Timestamp when vote was cast. + /// Allow `None` for 0-cost migration from current data + pub cast: Option, +} + +/// Information about a vote. +#[cw_serde] +pub struct VoteResponse { + /// None if no such vote, Some otherwise. + pub vote: Option, +} + +/// Information about all votes on the gauge +#[cw_serde] +pub struct ListVotesResponse { + pub votes: Vec, +} + +/// List all available options ordered by the option string. +/// Also returns the current voting power assigned to that option. +/// You will need to paginate to collect them all. +#[cw_serde] +pub struct ListOptionsResponse { + pub options: Vec<(String, Uint128)>, +} + +/// List the options that were selected in the last executed set. +#[cw_serde] +pub struct LastExecutedSetResponse { + /// `None` if no vote has been executed yet + pub votes: Option>, +} + +/// List the top options by power that would make it into the selected set. +/// Ordered from highest votes to lowest +#[cw_serde] +pub struct SelectedSetResponse { + pub votes: Vec<(String, Uint128)>, +} + +/// Queries the gauge requires from the adapter contract in order to function +#[cw_serde] +#[derive(QueryResponses)] +pub enum AdapterQueryMsg { + #[returns(AllOptionsResponse)] + AllOptions {}, + #[returns(CheckOptionResponse)] + CheckOption { option: String }, + #[returns(SampleGaugeMsgsResponse)] + SampleGaugeMsgs { + /// option along with weight + /// sum of all weights should be 1.0 (within rounding error) + selected: Vec<(String, Decimal)>, + }, +} + +#[cw_serde] +pub struct AllOptionsResponse { + pub options: Vec, +} + +#[cw_serde] +pub struct CheckOptionResponse { + pub valid: bool, +} + +#[cw_serde] +pub struct SampleGaugeMsgsResponse { + // NOTE: I think we will never need CustomMsg here, any reason we should include?? + pub execute: Vec, +} + +#[cw_serde] +pub struct MigrateMsg { + pub gauge_config: Option>, +} + +#[cw_serde] +#[derive(Default)] +pub struct GaugeMigrationConfig { + /// When the next epoch should be executed + pub next_epoch: Option, + /// If set, the gauge will be reset periodically + pub reset: Option, +} + +#[cw_serde] +pub struct ResetMigrationConfig { + /// How often to reset the gauge (in seconds) + pub reset_epoch: u64, + /// When to start the first reset + pub next_reset: u64, +} diff --git a/contracts/gauges/gauge/src/multitest/adapter.rs b/contracts/gauges/gauge/src/multitest/adapter.rs new file mode 100644 index 000000000..aa4af1b8e --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/adapter.rs @@ -0,0 +1,100 @@ +//! Gauge adapter contract to mock in tests. +//! I wrote it so that InstantiateMsg contains list of initially +//! available options. Query for CheckOption checks if option is already added, +//! otherwise returns true - option is valid. + +use cosmwasm_std::{ + coin, to_json_binary, Binary, Coin, CosmosMsg, Decimal, Deps, DepsMut, Empty, Env, MessageInfo, + Order, Response, StdError, StdResult, +}; +use cw_multi_test::{Contract, ContractWrapper}; +use cw_storage_plus::{Item, Map}; +use serde::{Deserialize, Serialize}; + +use crate::msg::{ + AdapterQueryMsg, AllOptionsResponse, CheckOptionResponse, SampleGaugeMsgsResponse, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstantiateMsg { + pub options: Vec, + pub to_distribute: Coin, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ExecuteMsg { + InvalidateOption { option: String }, + AddValidOption { option: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EmptyMsg {} + +const OPTIONS: Map = Map::new("options"); +const TO_DISTRIBUTE: Item = Item::new("to_spend"); + +fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + msg.options + .into_iter() + .try_for_each(|option| OPTIONS.save(deps.storage, option, &true))?; + TO_DISTRIBUTE.save(deps.storage, &msg.to_distribute)?; + Ok(Response::default()) +} + +fn execute( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::InvalidateOption { option } => { + OPTIONS.remove(deps.storage, option); + } + ExecuteMsg::AddValidOption { option } => { + OPTIONS.save(deps.storage, option, &true)?; + } + } + Ok(Response::new()) +} + +fn query(deps: Deps, _env: Env, msg: AdapterQueryMsg) -> Result { + match msg { + AdapterQueryMsg::AllOptions {} => to_json_binary(&AllOptionsResponse { + options: OPTIONS + .keys(deps.storage, None, None, Order::Ascending) + .collect::>>()?, + }), + AdapterQueryMsg::CheckOption { option } => to_json_binary(&CheckOptionResponse { + valid: OPTIONS.has(deps.storage, option), + }), + AdapterQueryMsg::SampleGaugeMsgs { selected } => { + let to_distribute = TO_DISTRIBUTE.load(deps.storage)?; + let mut weights_sum = Decimal::zero(); + let execute = selected + .into_iter() + .map(|(option, weight)| { + weights_sum += weight; + CosmosMsg::Bank(cosmwasm_std::BankMsg::Send { + to_address: option, + amount: vec![coin( + (to_distribute.amount * weight).u128(), + to_distribute.denom.clone(), + )], + }) + }) + .collect::>(); + to_json_binary(&SampleGaugeMsgsResponse { execute }) + } + } +} + +pub fn contract() -> Box> { + let contract = ContractWrapper::new_with_empty(execute, instantiate, query); + Box::new(contract) +} diff --git a/contracts/gauges/gauge/src/multitest/gauge.rs b/contracts/gauges/gauge/src/multitest/gauge.rs new file mode 100644 index 000000000..1b9543f4f --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/gauge.rs @@ -0,0 +1,792 @@ +use cosmwasm_std::{Addr, Decimal, Uint128}; +use dao_voting::voting::Vote; + +use super::suite::{Suite, SuiteBuilder}; + +use crate::error::ContractError; +use crate::msg::{GaugeMigrationConfig, GaugeResponse}; + +const EPOCH: u64 = 7 * 86_400; + +#[test] +fn create_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let response = suite.query_gauge(gauge_contract, 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); +} + +#[test] +fn gauge_can_upgrade_from_self() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + // now let's migrate the gauge and make sure nothing breaks + suite.auto_migrate_gauge(&gauge_contract, None).unwrap(); + + let response = suite.query_gauge(gauge_contract, 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); +} + +#[test] +fn gauge_migrate_with_next_epochs() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + // previous settings + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); + + // now let's migrate the gauge and make sure nothing breaks + let gauge_id = 0; + // change next epoch from 7 to 14 days + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: Some(suite.current_time() + 14 * 86400), + reset: None, + }, + )], + ) + .unwrap(); + + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 14 * 86400, + reset: None, + } + ); + + // try to migrate updating next epoch on nonexisting gauge_id + // actually generic error makes it more difficult to debug in presentable form, I think this is + // enough + let _err = suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + 420, + GaugeMigrationConfig { + next_epoch: Some(suite.current_time() + 14 * 86400), + reset: None, + }, + )], + ) + .unwrap_err(); +} + +/// attach adaptor in instantiate +#[test] +fn execute_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 1000u128 + ); +} + +/// Small helper method to setup the gauge contract. +/// Make sure that `voter` has voting power. +fn init_gauge(suite: &mut Suite, voters: &[&str]) -> Addr { + suite.next_block(); + suite + .propose_update_proposal_module(voters[0], None) + .unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + for voter in voters { + suite + .place_vote_single(*voter, proposal, Vote::Yes) + .unwrap(); + } + suite.next_block(); + suite.execute_single_proposal(voters[0], proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + // Second proposal module is cw proposal single, first one is newly added gauge + assert_eq!(proposal_modules.len(), 2); + proposal_modules[1].clone() +} + +#[test] +fn query_last_execution() { + let voter1 = "voter1"; + let voter2 = "voter2"; + + let reward_to_distribute = (2000, "ujuno"); + + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2, gauge_contract.as_str()], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + let gauge_id = 0; + + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + None, + "not executed yet" + ); + + // vote + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter1.to_owned())) + .unwrap(); + suite + .place_votes( + &gauge_contract, + voter2, + gauge_id, + vec![ + (gauge_contract.to_string(), Decimal::percent(40)), + (voter2.to_owned(), Decimal::percent(60)), + ], + ) + .unwrap(); + // wait until epoch passes + suite.advance_time(EPOCH); + // execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // should return the executed set now + let expected_votes = Some(vec![ + (voter1.to_owned(), 100u128.into()), + (voter2.to_string(), 60u128.into()), + (gauge_contract.to_string(), 40u128.into()), + ]); + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + expected_votes + ); + + // change votes + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter2.to_owned())) + .unwrap(); + suite + .place_vote(&gauge_contract, voter2, gauge_id, None) + .unwrap(); + + // wait until epoch passes + suite.advance_time(EPOCH); + + // should not change last execution yet + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + expected_votes + ); + + // execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // now it should be changed + assert_eq!( + suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(), + Some(vec![(voter2.to_owned(), 100u128.into())]) + ); +} + +#[test] +fn execute_gauge_twice_same_epoch() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], (1000, "ujuno"), None, None) // reward per + // epoch + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 1000u128 + ); + + // execution twice same time won't work + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + let next_epoch = suite.current_time() + EPOCH; + assert_eq!( + ContractError::EpochNotReached { + gauge_id, + current_epoch: suite.current_time(), + next_epoch + }, + err.downcast().unwrap() + ); + + // just before next epoch fails as well + suite.advance_time(EPOCH - 1); + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!( + ContractError::EpochNotReached { + gauge_id, + current_epoch: suite.current_time(), + next_epoch + }, + err.downcast().unwrap() + ); + + // another epoch is fine + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter1, reward_to_distribute.1).unwrap(), + 2000u128 + ); +} + +#[test] +fn execute_stopped_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // stop the gauge by not-owner + let err = suite + .stop_gauge(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + // stop the gauge by owner + suite + .stop_gauge(&gauge_contract, suite.owner.clone(), gauge_id) + .unwrap(); + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + // Despite gauge being stopped, user + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + let err = suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap_err(); + assert_eq!( + ContractError::GaugeStopped(gauge_id), + err.downcast().unwrap() + ); +} + +#[test] +fn update_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + let gauge_contract = init_gauge(&mut suite, &[voter1, voter2]); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let second_gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "uusdc"), + None, + None, + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ] + ); + + // update parameters on the first gauge + let owner = suite.owner.clone(); + let new_epoch = EPOCH * 2; + let new_min_percent = Some(Decimal::percent(10)); + let new_max_options = 15; + let new_max_available_percentage = Some(Decimal::percent(5)); + suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + new_max_options, + new_max_available_percentage, + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: new_epoch, + min_percent_selected: new_min_percent, + max_options_selected: new_max_options, + max_available_percentage: new_max_available_percentage, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ] + ); + + // clean setting of min_percent_selected on second gauge + suite + .update_gauge( + &owner, + gauge_contract.clone(), + 1, + None, + Some(Decimal::zero()), + None, + None, + ) + .unwrap(); + + let response = suite.query_gauges(gauge_contract.clone()).unwrap(); + assert_eq!( + response, + vec![ + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: new_epoch, + min_percent_selected: new_min_percent, + max_options_selected: new_max_options, + max_available_percentage: new_max_available_percentage, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + }, + GaugeResponse { + id: 1, + title: "gauge".to_owned(), + adapter: second_gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: None, + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ] + ); + + // Not owner cannot update gauges + let err = suite + .update_gauge( + "notowner", + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + new_max_options, + None, + ) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + let err = suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + 50, + new_min_percent, + new_max_options, + None, + ) + .unwrap_err(); + assert_eq!(ContractError::EpochSizeTooShort {}, err.downcast().unwrap()); + + let err = suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + Some(Decimal::one()), + new_max_options, + None, + ) + .unwrap_err(); + assert_eq!( + ContractError::MinPercentSelectedTooBig {}, + err.downcast().unwrap() + ); + + let err = suite + .update_gauge( + &owner, + gauge_contract.clone(), + 0, + new_epoch, + new_min_percent, + 0, + None, + ) + .unwrap_err(); + assert_eq!( + ContractError::MaxOptionsSelectedTooSmall {}, + err.downcast().unwrap() + ); + + let err = suite + .update_gauge( + &owner, + gauge_contract, + 1, + None, + Some(Decimal::zero()), + None, + Some(Decimal::percent(101)), + ) + .unwrap_err(); + assert_eq!( + ContractError::MaxAvailablePercentTooBig {}, + err.downcast().unwrap() + ); +} diff --git a/contracts/gauges/gauge/src/multitest/mod.rs b/contracts/gauges/gauge/src/multitest/mod.rs new file mode 100644 index 000000000..7ecb09742 --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/mod.rs @@ -0,0 +1,6 @@ +mod adapter; +mod gauge; +mod reset; +mod suite; +mod tally; +mod voting; diff --git a/contracts/gauges/gauge/src/multitest/reset.rs b/contracts/gauges/gauge/src/multitest/reset.rs new file mode 100644 index 000000000..ffe572bd4 --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/reset.rs @@ -0,0 +1,417 @@ +use cosmwasm_std::{Decimal, StdError, Uint128}; +use dao_voting::voting::Vote; + +use crate::{ + msg::{GaugeMigrationConfig, GaugeResponse, ResetMigrationConfig}, + multitest::suite::SuiteBuilder, + ContractError, +}; + +const EPOCH: u64 = 7 * 86_400; +const RESET_EPOCH: u64 = 30 * 86_400; + +#[test] +fn basic_gauge_reset() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config( + &[voter1, voter2], + reward_to_distribute, + None, + RESET_EPOCH, + ) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // cannot reset before epoch has passed + assert_eq!( + ContractError::ResetEpochNotPassed {}, + suite + .reset_gauge("anyone", &gauge_contract, gauge_id, 10) + .unwrap_err() + .downcast() + .unwrap() + ); + + // reset + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 100) // 100 is way more than needed + .unwrap(); + + // check that gauge was reset + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![]); + let votes = suite.query_list_votes(&gauge_contract, gauge_id).unwrap(); + assert_eq!(votes, vec![]); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + None, + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + None, + ); + // options should still be there + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // actually execute + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + assert_eq!( + suite + .query_balance(suite.core.as_str(), reward_to_distribute.1) + .unwrap(), + reward_to_distribute.0, + "nothing should be distributed yet, since all votes were reset" + ); + + // vote again + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter2.to_owned()), + ) + .unwrap(); + + // check that vote counts + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![("voter2".to_owned(), Uint128::new(100))]); + + // another epoch is fine + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + suite.query_balance(voter2, reward_to_distribute.1).unwrap(), + 2000u128 + ); +} + +#[test] +fn gauge_migrate_with_reset() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + // setup gauge + suite.next_block(); + suite.propose_update_proposal_module(voter1, None).unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite.next_block(); + suite.execute_single_proposal(voter1, proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + assert_eq!(proposal_modules.len(), 2); + let gauge_contract = proposal_modules[1].clone(); + + // create adapter + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + // previous settings + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: None, + } + ); + + // now let's migrate the gauge and make sure nothing breaks + let gauge_id = 0; + // try to migrate to past reset should fail + assert_eq!( + ContractError::from(StdError::generic_err( + "Next reset value cannot be earlier then current epoch!" + )), + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() - 1, + }), + }, + )], + ) + .unwrap_err() + .downcast() + .unwrap() + ); + + // migrate to reset epoch + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() + 100, + }), + }, + )], + ) + .unwrap(); + + // check that gauge was migrated + let response = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!( + response, + GaugeResponse { + id: 0, + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: EPOCH, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: suite.current_time() + 7 * 86400, + reset: Some(crate::state::Reset { + last: None, + reset_each: RESET_EPOCH, + next: suite.current_time() + 100, + }), + } + ); +} + +#[test] +fn gauge_migrate_keeps_last_reset() { + let voter1 = "voter1"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100)]) + .build(); + + // setup gauge + suite.next_block(); + suite.propose_update_proposal_module(voter1, None).unwrap(); + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite.next_block(); + suite.execute_single_proposal(voter1, proposal).unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + assert_eq!(proposal_modules.len(), 2); + let gauge_contract = proposal_modules[1].clone(); + + // create adapter + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2"], + (1000, "ujuno"), + None, + Some(RESET_EPOCH), + ) + .unwrap(); + let gauge_id = 0; + + // reset gauge once before migration + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); + let gauge = suite.query_gauge(gauge_contract.clone(), gauge_id).unwrap(); + assert_eq!(gauge.reset.unwrap().last, Some(suite.current_time())); + + // now let's migrate the gauge and make sure nothing breaks + suite + .auto_migrate_gauge( + &gauge_contract, + vec![( + gauge_id, + GaugeMigrationConfig { + next_epoch: None, + reset: Some(ResetMigrationConfig { + reset_epoch: RESET_EPOCH, + next_reset: suite.current_time() + 100, + }), + }, + )], + ) + .unwrap(); + + // check that last reset is still the same + let gauge = suite.query_gauge(gauge_contract.clone(), 0).unwrap(); + assert_eq!(gauge.reset.unwrap().last, Some(suite.current_time())); +} + +#[test] +fn partial_reset() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (2000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config( + &[voter1, voter2], + reward_to_distribute, + None, + RESET_EPOCH, + ) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for the gauge options + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter2.to_owned()), + ) + .unwrap(); + + // start resetting + suite.advance_time(RESET_EPOCH); + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); + + // try to vote during reset + assert_eq!( + ContractError::GaugeResetting(gauge_id), + suite + .place_vote(&gauge_contract, voter1, gauge_id, Some(voter2.to_owned())) + .unwrap_err() + .downcast() + .unwrap() + ); + // check selected set query + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![]); + // check votes list + let votes = suite.query_list_votes(&gauge_contract, gauge_id).unwrap(); + assert_eq!(votes, vec![]); + + // finish resetting + suite + .reset_gauge("someone", &gauge_contract, gauge_id, 1) + .unwrap(); +} diff --git a/contracts/gauges/gauge/src/multitest/suite.rs b/contracts/gauges/gauge/src/multitest/suite.rs new file mode 100644 index 000000000..d6b695e8c --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/suite.rs @@ -0,0 +1,713 @@ +use anyhow::Result as AnyResult; + +use cosmwasm_std::{ + coin, to_json_binary, Addr, Coin, CosmosMsg, Decimal, StdResult, Uint128, WasmMsg, +}; +use cw4::Member; +use cw_multi_test::{App, AppResponse, ContractWrapper, Executor}; +use cw_utils::Duration; +use dao_interface::{ + msg::{ + ExecuteMsg as CoreExecuteMsg, InstantiateMsg as CoreInstantiateMsg, + QueryMsg as CoreQueryMsg, + }, + state::{Admin, ModuleInstantiateInfo, ProposalModule}, +}; +use dao_proposal_single::{ + msg::ExecuteMsg as ProposalSingleExecuteMsg, + msg::InstantiateMsg as ProposalSingleInstantiateMsg, msg::QueryMsg as ProposalSingleQueryMsg, + query::ProposalListResponse, +}; +use dao_voting::{ + pre_propose::PreProposeInfo, + proposal::SingleChoiceProposeMsg, + threshold::{PercentageThreshold, Threshold}, + voting::Vote, +}; +use dao_voting_cw4::msg::InstantiateMsg as VotingInstantiateMsg; + +use super::adapter::{ + contract as adapter_contract, ExecuteMsg as AdapterExecuteMsg, + InstantiateMsg as AdapterInstantiateMsg, +}; +use crate::msg::{ + ExecuteMsg, GaugeConfig, GaugeMigrationConfig, GaugeResponse, InstantiateMsg, + LastExecutedSetResponse, ListGaugesResponse, ListOptionsResponse, ListVotesResponse, + MigrateMsg, QueryMsg, SelectedSetResponse, VoteInfo, VoteResponse, +}; + +type GaugeId = u64; + +pub const BLOCK_TIME: u64 = 5; + +fn store_gauge(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate), + ); + + app.store_code(contract) +} + +fn store_group(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + cw4_group::contract::execute, + cw4_group::contract::instantiate, + cw4_group::contract::query, + )); + + app.store_code(contract) +} + +fn store_voting(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + dao_voting_cw4::contract::execute, + dao_voting_cw4::contract::instantiate, + dao_voting_cw4::contract::query, + ) + .with_reply_empty(dao_voting_cw4::contract::reply), + ); + + app.store_code(contract) +} + +fn store_proposal_single(app: &mut App) -> u64 { + let contract = Box::new(ContractWrapper::new_with_empty( + dao_proposal_single::contract::execute, + dao_proposal_single::contract::instantiate, + dao_proposal_single::contract::query, + )); + + app.store_code(contract) +} + +fn store_core(app: &mut App) -> u64 { + let contract = Box::new( + ContractWrapper::new_with_empty( + dao_dao_core::contract::execute, + dao_dao_core::contract::instantiate, + dao_dao_core::contract::query, + ) + .with_reply_empty(dao_dao_core::contract::reply), + ); + + app.store_code(contract) +} + +#[derive(Debug)] +pub struct SuiteBuilder { + voting_members: Vec, + initial_core_balance: Option, +} + +impl SuiteBuilder { + pub fn new() -> Self { + Self { + voting_members: vec![], + initial_core_balance: None, + } + } + + pub fn with_core_balance(mut self, balance: (u128, &str)) -> Self { + self.initial_core_balance = Some(coin(balance.0, balance.1)); + self + } + + pub fn with_voting_members(mut self, members: &[(&str, u64)]) -> Self { + self.voting_members = members + .iter() + .map(|(addr, weight)| Member { + addr: addr.to_string(), + weight: *weight, + }) + .collect::>(); + self + } + + #[track_caller] + pub fn build(self) -> Suite { + let mut app = App::default(); + let owner = Addr::unchecked("owner"); + + // instantiate cw4-voting as voting contract + let group_code_id = store_group(&mut app); + let voting_code_id = store_voting(&mut app); + let voting_module = ModuleInstantiateInfo { + code_id: voting_code_id, + msg: to_json_binary(&VotingInstantiateMsg { + group_contract: dao_voting_cw4::msg::GroupContract::New { + cw4_group_code_id: group_code_id, + initial_members: self.voting_members, + }, + }) + .unwrap(), + admin: Some(Admin::Address { + addr: owner.to_string(), + }), + label: "CW4 Voting Contract".to_owned(), + funds: vec![], + }; + + // instantiate proposal_single module + let proposal_single_code_id = store_proposal_single(&mut app); + let proposal_module = ModuleInstantiateInfo { + code_id: proposal_single_code_id, + msg: to_json_binary(&ProposalSingleInstantiateMsg { + threshold: Threshold::AbsolutePercentage { + percentage: PercentageThreshold::Majority {}, + }, + max_voting_period: Duration::Time(66666666), + min_voting_period: None, + allow_revoting: false, + only_members_execute: false, + pre_propose_info: PreProposeInfo::AnyoneMayPropose {}, + close_proposal_on_execution_failure: false, + veto: None, + }) + .unwrap(), + admin: Some(Admin::Address { + addr: owner.to_string(), + }), + label: "Proposal Single Contract".to_owned(), + funds: vec![], + }; + + // intantiate core contract, + let core_code_id = store_core(&mut app); + let core = app + .instantiate_contract( + core_code_id, + owner.clone(), + &CoreInstantiateMsg { + admin: Some(owner.to_string()), + name: "CW Core contract".to_owned(), + description: "Hub between voting end executing".to_owned(), + image_url: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: voting_module, + proposal_modules_instantiate_info: vec![proposal_module], + initial_items: None, + dao_uri: None, + }, + &[], + "CW CORE", + None, + ) + .unwrap(); + + if let Some(core_balance) = self.initial_core_balance { + app.init_modules(|router, _, storage| -> AnyResult<()> { + router.bank.init_balance(storage, &core, vec![core_balance]) + }) + .unwrap(); + } + + let voting_contract: Addr = app + .wrap() + .query_wasm_smart(&core, &CoreQueryMsg::VotingModule {}) + .unwrap(); + let proposal_single_contract: Vec = app + .wrap() + .query_wasm_smart( + &core, + &CoreQueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + + let gauge_code_id = store_gauge(&mut app); + let gauge_adapter_code_id = app.store_code(adapter_contract()); + + Suite { + owner: owner.to_string(), + app, + core, + voting: voting_contract, + proposal_single: proposal_single_contract[0].address.clone(), + gauge_code_id, + gauge_adapter_code_id, + } + } +} + +pub struct Suite { + pub owner: String, + pub app: App, + pub core: Addr, + voting: Addr, + proposal_single: Addr, + pub gauge_code_id: u64, + gauge_adapter_code_id: u64, +} + +impl Suite { + pub fn advance_blocks(&mut self, blocks: u64) { + self.app.update_block(|block| { + block.time = block.time.plus_seconds(BLOCK_TIME * blocks); + block.height += blocks; + }); + } + + pub fn advance_time(&mut self, seconds: u64) { + self.app.update_block(|block| { + block.time = block.time.plus_seconds(seconds); + block.height += seconds / BLOCK_TIME; + }); + } + + pub fn next_block(&mut self) { + self.advance_blocks(1) + } + + pub fn current_time(&self) -> u64 { + self.app.block_info().time.seconds() + } + + pub fn stop_gauge( + &mut self, + gauge: &Addr, + sender: impl Into, + gauge_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge.clone(), + &ExecuteMsg::StopGauge { gauge: gauge_id }, + &[], + ) + } + + pub fn add_option( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::AddOption { + gauge: gauge_id, + option: option.into(), + }, + &[], + ) + } + + pub fn remove_option( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::RemoveOption { + gauge: gauge_id, + option: option.into(), + }, + &[], + ) + } + + /// Helper to remove an option from the test gauge adapter + pub fn invalidate_option( + &mut self, + gauge_adapter: &Addr, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(self.owner.clone()), + gauge_adapter.clone(), + &AdapterExecuteMsg::InvalidateOption { + option: option.into(), + }, + &[], + ) + } + + /// Helper to add an option to the test gauge adapter + pub fn add_valid_option( + &mut self, + gauge_adapter: &Addr, + option: impl Into, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(self.owner.clone()), + gauge_adapter.clone(), + &AdapterExecuteMsg::AddValidOption { + option: option.into(), + }, + &[], + ) + } + + /// Helper to vote for a single option + pub fn place_vote( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + option: impl Into>, + ) -> AnyResult { + self.place_votes( + gauge, + voter, + gauge_id, + option.into().map(|o| vec![(o, Decimal::one())]), + ) + } + + pub fn place_votes( + &mut self, + gauge: &Addr, + voter: impl Into, + gauge_id: u64, + votes: impl Into>>, + ) -> AnyResult { + let votes = votes.into().map(|v| { + v.into_iter() + .map(|(option, weight)| crate::state::Vote { option, weight }) + .collect::>() + }); + self.app.execute_contract( + Addr::unchecked(voter), + gauge.clone(), + &ExecuteMsg::PlaceVotes { + gauge: gauge_id, + votes, + }, + &[], + ) + } + + pub fn execute_options( + &mut self, + gauge: &Addr, + sender: impl Into, + gauge_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge.clone(), + &ExecuteMsg::Execute { gauge: gauge_id }, + &[], + ) + } + + pub fn query_gauge(&self, gauge_contract: Addr, id: u64) -> StdResult { + self.app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::Gauge { id }) + } + + pub fn query_gauges(&self, gauge_contract: Addr) -> StdResult> { + Ok(self + .app + .wrap() + .query_wasm_smart::( + gauge_contract, + &QueryMsg::ListGauges { + start_after: None, + limit: None, + }, + )? + .gauges) + } + + pub fn query_selected_set( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult> { + let set: SelectedSetResponse = self + .app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::SelectedSet { gauge: id })?; + Ok(set.votes) + } + + pub fn query_last_executed_set( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult>> { + let set: LastExecutedSetResponse = self + .app + .wrap() + .query_wasm_smart(gauge_contract, &QueryMsg::LastExecutedSet { gauge: id })?; + Ok(set.votes) + } + + pub fn query_list_options( + &self, + gauge_contract: &Addr, + id: u64, + ) -> StdResult> { + let set: ListOptionsResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::ListOptions { + gauge: id, + start_after: None, + limit: None, + }, + )?; + Ok(set.options) + } + + pub fn query_vote( + &self, + gauge_contract: &Addr, + id: u64, + voter: impl Into, + ) -> StdResult> { + let vote: VoteResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::Vote { + gauge: id, + voter: voter.into(), + }, + )?; + Ok(vote.vote) + } + + pub fn query_list_votes(&self, gauge_contract: &Addr, id: u64) -> StdResult> { + let vote: ListVotesResponse = self.app.wrap().query_wasm_smart( + gauge_contract, + &QueryMsg::ListVotes { + gauge: id, + start_after: None, + limit: None, + }, + )?; + Ok(vote.votes) + } + + // ----------------------------------------------------- + + pub fn propose_update_proposal_module( + &mut self, + proposer: impl Into, + gauge_config: impl Into>>, + ) -> AnyResult { + let propose_msg = ProposalSingleExecuteMsg::Propose(SingleChoiceProposeMsg { + title: "gauge as proposal module".to_owned(), + description: "Propose core to set gauge as proposal module".to_owned(), + msgs: vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: self.core.to_string(), + msg: to_json_binary(&CoreExecuteMsg::UpdateProposalModules { + to_add: vec![ModuleInstantiateInfo { + code_id: self.gauge_code_id, + msg: to_json_binary(&InstantiateMsg { + voting_powers: self.voting.to_string(), + owner: self.owner.clone(), + gauges: gauge_config.into(), + })?, + admin: Some(Admin::Address { + addr: self.owner.clone(), + }), + label: "CW4 Voting Contract".to_owned(), + funds: vec![], + }], + to_disable: vec![], + })?, + funds: vec![], + })], + proposer: None, + vote: None, + }); + self.app.execute_contract( + Addr::unchecked(proposer), + self.proposal_single.clone(), + &propose_msg, + &[], + ) + } + + pub fn instantiate_adapter_and_create_gauge( + &mut self, + gauge_contract: Addr, + options: &[&str], + to_distribute: (u128, &str), + max_available_percentage: impl Into>, + reset_epoch: impl Into>, + ) -> AnyResult { + let option = self.instantiate_adapter_and_return_config( + options, + to_distribute, + max_available_percentage, + reset_epoch, + )?; + let gauge_adapter = option.adapter.clone(); + self.app.execute_contract( + Addr::unchecked(&self.owner), + gauge_contract, + &ExecuteMsg::CreateGauge(option), + &[], + )?; + Ok(Addr::unchecked(gauge_adapter)) + } + + pub fn instantiate_adapter_and_return_config( + &mut self, + options: &[&str], + to_distribute: (u128, &str), + max_available_percentage: impl Into>, + reset_epoch: impl Into>, + ) -> AnyResult { + let gauge_adapter = self.app.instantiate_contract( + self.gauge_adapter_code_id, + Addr::unchecked(&self.owner), + &AdapterInstantiateMsg { + options: options.iter().map(|&s| s.into()).collect(), + to_distribute: coin(to_distribute.0, to_distribute.1), + }, + &[], + "gauge adapter", + None, + )?; + + Ok(GaugeConfig { + title: "gauge".to_owned(), + adapter: gauge_adapter.to_string(), + epoch_size: 7 * 86400, + min_percent_selected: Some(Decimal::percent(5)), + max_options_selected: 10, + max_available_percentage: max_available_percentage.into(), + reset_epoch: reset_epoch.into(), + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn update_gauge( + &mut self, + sender: &str, + gauge_contract: Addr, + gauge_id: u64, + epoch_size: impl Into>, + min_percent_selected: Option, + max_options_selected: impl Into>, + max_available_percentage: impl Into>, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge_contract, + &ExecuteMsg::UpdateGauge { + gauge_id, + epoch_size: epoch_size.into(), + min_percent_selected, + max_options_selected: max_options_selected.into(), + max_available_percentage: max_available_percentage.into(), + }, + &[], + ) + } + + pub fn reset_gauge( + &mut self, + sender: &str, + gauge_contract: &Addr, + gauge: u64, + batch_size: u32, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(sender), + gauge_contract.clone(), + &ExecuteMsg::ResetGauge { gauge, batch_size }, + &[], + ) + } + + pub fn place_vote_single( + &mut self, + voter: impl Into, + proposal_id: u64, + vote: Vote, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(voter), + self.proposal_single.clone(), + &ProposalSingleExecuteMsg::Vote { + proposal_id, + vote, + rationale: None, + }, + &[], + ) + } + + pub fn execute_single_proposal( + &mut self, + executor: impl Into, + proposal_id: u64, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(executor), + self.proposal_single.clone(), + &ProposalSingleExecuteMsg::Execute { proposal_id }, + &[], + ) + } + + pub fn list_proposals(&self) -> StdResult> { + let list: ProposalListResponse = self.app.wrap().query_wasm_smart( + self.proposal_single.clone(), + &ProposalSingleQueryMsg::ListProposals { + start_after: None, + limit: None, + }, + )?; + Ok(list.proposals.into_iter().map(|prop| prop.id).collect()) + } + + pub fn query_proposal_modules(&self) -> StdResult> { + let proposal_module: Vec = self + .app + .wrap() + .query_wasm_smart( + self.core.clone(), + &CoreQueryMsg::ProposalModules { + start_after: None, + limit: None, + }, + ) + .unwrap(); + proposal_module + .into_iter() + .map(|pm| Ok(pm.address)) + .collect() + } + + pub fn query_balance(&self, account: &str, denom: &str) -> StdResult { + let balance = self.app.wrap().query_balance(account, denom)?; + Ok(balance.amount.u128()) + } + + pub fn auto_migrate_gauge( + &mut self, + gauge: &Addr, + gauge_config: impl Into>>, + ) -> AnyResult { + let sender = Addr::unchecked(&self.owner); + + self.app.migrate_contract( + sender, + gauge.clone(), + &MigrateMsg { + gauge_config: gauge_config.into(), + }, + self.gauge_code_id, + ) + } +} diff --git a/contracts/gauges/gauge/src/multitest/tally.rs b/contracts/gauges/gauge/src/multitest/tally.rs new file mode 100644 index 000000000..9de07ecb4 --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/tally.rs @@ -0,0 +1,333 @@ +use cosmwasm_std::Uint128; +use dao_voting::voting::Vote; + +use super::suite::SuiteBuilder; + +#[test] +fn multiple_options_one_gauge() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let voter3 = "voter3"; + let voter4 = "voter4"; + let voter5 = "voter5"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority... + (voter2, 120), + (voter3, 130), + (voter4, 140), + (voter5, 150), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2", "option3", "option4", "option5"], + reward_to_distribute, + None, + None, + ) + .unwrap(); + let gauge_id = 0; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("option1".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter3.to_owned(), + gauge_id, + Some("option3".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter4.to_owned(), + gauge_id, + Some("option4".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter5.to_owned(), + gauge_id, + Some("option5".into()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(600)), + ("option5".to_owned(), Uint128::new(150)), + ("option4".to_owned(), Uint128::new(140)), + ("option3".to_owned(), Uint128::new(130)), + ("option2".to_owned(), Uint128::new(120)) + ] + ); + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("option2".into()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + selected_set, + vec![ + ("option2".to_owned(), Uint128::new(720)), + ("option5".to_owned(), Uint128::new(150)), + ("option4".to_owned(), Uint128::new(140)), + ("option3".to_owned(), Uint128::new(130)), + ] + ); +} + +/// create one in instantiate, other later via create +#[test] +fn multiple_options_two_gauges() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let voter3 = "voter3"; + let voter4 = "voter4"; + let voter5 = "voter5"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority + (voter2, 120), + (voter3, 130), + (voter4, 140), + (voter5, 150), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config( + &["option1", "option2"], + reward_to_distribute, + None, + None, + ) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let first_gauge_id = 0; + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option3", "option4", "option5"], + reward_to_distribute, + None, + None, + ) + .unwrap(); + let second_gauge_id = 1; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter3.to_owned(), + second_gauge_id, + Some("option3".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter4.to_owned(), + second_gauge_id, + Some("option5".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter5.to_owned(), + second_gauge_id, + Some("option5".into()), + ) + .unwrap(); + + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![("option2".to_owned(), Uint128::new(720))] + ); + + let selected_set = suite + .query_selected_set(&gauge_contract, second_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![ + ("option5".to_owned(), Uint128::new(290)), + ("option3".to_owned(), Uint128::new(130)), + ] + ); +} + +#[test] +fn not_voted_options_are_not_selected() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[ + (voter1, 600), // to have majority + (voter2, 120), + ]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &["option1", "option2", "option3", "option4"], + reward_to_distribute, + None, + None, + ) + .unwrap(); + let first_gauge_id = 0; + + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option1".into()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(600)), + ("option2".to_owned(), Uint128::new(120)), + ] + ); + + // first voter changes vote to option2 + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + first_gauge_id, + Some("option2".into()), + ) + .unwrap(); + let selected_set = suite + .query_selected_set(&gauge_contract, first_gauge_id) + .unwrap(); + assert_eq!( + selected_set, + vec![("option2".to_owned(), Uint128::new(720)),] + ); +} diff --git a/contracts/gauges/gauge/src/multitest/voting.rs b/contracts/gauges/gauge/src/multitest/voting.rs new file mode 100644 index 000000000..c1e934666 --- /dev/null +++ b/contracts/gauges/gauge/src/multitest/voting.rs @@ -0,0 +1,674 @@ +use cosmwasm_std::{Decimal, Uint128}; +use dao_voting::voting::Vote; + +use super::suite::SuiteBuilder; +use crate::error::ContractError; +use crate::msg::VoteInfo; + +const EPOCH: u64 = 7 * 86_400; + +#[test] +fn add_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // gauge returns list all options; it does query adapter at initialization + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!(options.len(), 2); + + // add more valid options to gauge adapter + suite + .add_valid_option(&gauge_adapter, "addedoption1") + .unwrap(); + suite + .add_valid_option(&gauge_adapter, "addedoption2") + .unwrap(); + + // Voting members can add options + suite + .add_option(&gauge_contract, voter1, gauge_id, "addedoption1") + .unwrap(); + suite + .add_option(&gauge_contract, voter2, gauge_id, "addedoption2") + .unwrap(); + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // added options are automatically voted for by creators + assert_eq!( + options, + vec![ + ("addedoption1".to_owned(), Uint128::zero()), + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // add another valid option to gauge adapter + suite + .add_valid_option(&gauge_adapter, "addedoption3") + .unwrap(); + // Non-voting members cannot add options + let err = suite + .add_option(&gauge_contract, "random_voter", gauge_id, "addedoption3") + .unwrap_err(); + assert_eq!( + ContractError::NoVotingPower("random_voter".to_owned()), + err.downcast().unwrap() + ); +} + +#[test] +fn remove_option() { + let owner = "owner"; + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // gauge returns list all options; it does query adapter at initialization + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // add new valid options to the gauge adapter + suite.add_valid_option(&adapter, "addedoption1").unwrap(); + suite.add_valid_option(&adapter, "addedoption2").unwrap(); + + // Voting members can add options + suite + .add_option(&gauge_contract, voter1, gauge_id, "addedoption1") + .unwrap(); + suite + .add_option(&gauge_contract, voter2, gauge_id, "addedoption2") + .unwrap(); + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // added options are automatically voted for by creators + assert_eq!( + options, + vec![ + ("addedoption1".to_owned(), Uint128::zero()), + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + // owner can remove an option that has been added already + suite + .remove_option(&gauge_contract, owner, gauge_id, "addedoption1") + .unwrap(); + + // Anyone else cannot remove options + let err = suite + .remove_option(&gauge_contract, voter1, gauge_id, "addedoption2") + .unwrap_err(); + + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + // one has been removed + assert_eq!( + options, + vec![ + ("addedoption2".to_owned(), Uint128::zero()), + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); + + suite.invalidate_option(&adapter, "addedoption2").unwrap(); + + // owner can remove an option that is no longer valid + suite + .remove_option(&gauge_contract, owner, gauge_id, "addedoption2") + .unwrap(); + + // Both options are now removed + let options = suite.query_list_options(&gauge_contract, gauge_id).unwrap(); + assert_eq!( + options, + vec![ + ("voter1".to_owned(), Uint128::zero()), + ("voter2".to_owned(), Uint128::zero()) + ] + ); +} + +fn simple_vote( + voter: &str, + option: &str, + percentage: u64, + cast: impl Into>, +) -> VoteInfo { + VoteInfo { + voter: voter.to_string(), + votes: vec![crate::state::Vote { + option: option.to_string(), + weight: Decimal::percent(percentage), + }], + cast: cast.into(), + } +} + +fn multi_vote(voter: &str, votes: &[(&str, u64)], cast: impl Into>) -> VoteInfo { + let votes = votes + .iter() + .map(|(opt, percentage)| crate::state::Vote { + option: opt.to_string(), + weight: Decimal::percent(*percentage), + }) + .collect(); + VoteInfo { + voter: voter.to_string(), + votes, + cast: cast.into(), + } +} + +#[test] +fn vote_for_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 90, suite.current_time()), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::new(90))]); + + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 90, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // placing vote again overwrites previous ones + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![("option1".to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![("option1".to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, "option1", 90, suite.current_time()), + simple_vote(voter2, "option1", 90, suite.current_time()), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // vote for non-existing option + let err = suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some("random option".to_owned()), + ) + .unwrap_err(); + assert_eq!( + ContractError::OptionDoesNotExists { + option: "random option".to_owned(), + gauge_id + }, + err.downcast().unwrap() + ); +} + +#[test] +fn remove_vote() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100, suite.current_time()), + simple_vote(voter2, voter1, 100, suite.current_time()), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // remove vote + suite + .place_vote(&gauge_contract, voter1.to_owned(), gauge_id, None) + .unwrap(); + assert_eq!( + vec![simple_vote(voter2, voter1, 100, suite.current_time())], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + None + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + Some(simple_vote(voter2, voter1, 100, suite.current_time())), + ); + + // remove nonexisting vote + let err = suite + .place_vote(&gauge_contract, voter1.to_owned(), gauge_id, None) + .unwrap_err(); + assert_eq!( + ContractError::CannotRemoveNonexistingVote {}, + err.downcast().unwrap() + ); +} + +#[test] +fn votes_stays_the_same_after_execution() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let reward_to_distribute = (1000, "ujuno"); + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .with_core_balance(reward_to_distribute) + .build(); + + suite.next_block(); + let gauge_config = suite + .instantiate_adapter_and_return_config(&[voter1, voter2], reward_to_distribute, None, None) + .unwrap(); + suite + .propose_update_proposal_module(voter1.to_string(), vec![gauge_config]) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + let gauge_contract = proposal_modules[1].clone(); + + let gauge_id = 0; + + // vote for one of the options in gauge + suite + .place_vote( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(voter1.to_owned()), // option to vote for + ) + .unwrap(); + suite + .place_vote( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(voter1.to_owned()), + ) + .unwrap(); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // voter1 was option voted for with two 100 voting powers combined + assert_eq!(selected_set, vec![("voter1".to_owned(), Uint128::new(200))]); + + // before advancing specified epoch tally won't get sampled + suite.advance_time(EPOCH); + + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100, suite.current_time() - EPOCH), + simple_vote(voter2, voter1, 100, suite.current_time() - EPOCH) + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100, suite.current_time() - EPOCH), + simple_vote(voter2, voter1, 100, suite.current_time() - EPOCH) + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter1).unwrap(), + Some(simple_vote( + voter1, + voter1, + 100, + suite.current_time() - EPOCH + )), + ); + assert_eq!( + suite.query_vote(&gauge_contract, gauge_id, voter2).unwrap(), + Some(simple_vote( + voter2, + voter1, + 100, + suite.current_time() - EPOCH + )), + ); +} + +#[test] +fn vote_for_max_capped_option() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 100)]) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + Some(Decimal::percent(10)), + None, + ) + .unwrap(); + + let gauge_id = 0; // first created gauge + + // wait until epoch passes + suite.advance_time(EPOCH); + + // add more valid options to gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + + // vote 100% voting power on 'voter1' option (100 weight) + suite + .place_vote( + &gauge_contract, + voter1, + gauge_id, + Some("option1".to_owned()), + ) + .unwrap(); + // vote 10% voting power on 'voter2' option (10 weight) + suite + .place_votes( + &gauge_contract, + voter2, + gauge_id, + vec![("option2".to_owned(), Decimal::percent(10))], + ) + .unwrap(); + + assert_eq!( + vec![ + multi_vote(voter1, &[("option1", 100)], suite.current_time()), + multi_vote(voter2, &[("option2", 10)], suite.current_time()), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + // Despite 'option1' having 100 voting power and option2 having 10 voting power, + // because of max vote cap set to 10% now 'option1' will have its power decreased to 10% * 110 + // 'option2' stays at 10 voting power as it was below 10% of total votes + assert_eq!( + selected_set, + vec![ + ("option1".to_owned(), Uint128::new(11)), + ("option2".to_owned(), Uint128::new(10)) + ] + ); +} diff --git a/contracts/gauges/gauge/src/state.rs b/contracts/gauges/gauge/src/state.rs new file mode 100644 index 000000000..30ad5a974 --- /dev/null +++ b/contracts/gauges/gauge/src/state.rs @@ -0,0 +1,717 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Decimal, Deps, Env, Order, StdResult, Storage, Uint128}; +use cw_storage_plus::{Bound, Index, IndexList, IndexedMap, Item, Map, MultiIndex}; +use cw_utils::maybe_addr; + +use crate::msg::VoteInfo; + +/// Type alias for u64 to make the map types a bit more self-explanatory +pub type GaugeId = u64; + +pub const CONFIG: Item = Item::new("config"); +pub const GAUGES: Map = Map::new("gauges"); +const LAST_ID: Item = Item::new("last_id"); + +/// Get ID for gauge registration and increment value in storage +pub fn fetch_last_id(storage: &mut dyn Storage) -> StdResult { + let last_id = LAST_ID.load(storage).unwrap_or_default(); + LAST_ID.save(storage, &(last_id + 1u64))?; + Ok(last_id) +} + +/// This lets us find and update any vote given both voter and gauge. +/// It also lets us iterate over all votes by a given voter on all gauges +/// or by a given gauge id. This is needed when a voter weight changes +/// in order to update the guage. +pub fn votes() -> Votes<'static> { + Votes::new("votes", "votes__gaugeid") +} + +// settings for pagination +const MAX_LIMIT: u32 = 100; +const DEFAULT_LIMIT: u32 = 30; + +#[cw_serde] +pub struct Config { + /// Address of contract to that contains all voting powers (where we query and listen to hooks) + pub voting_powers: Addr, + /// Address that can add new gauges or stop them + pub owner: Addr, + /// Address of DAO core module resposible for instantiation and execution of messages + pub dao_core: Addr, +} + +#[cw_serde] +pub struct Gauge { + /// Descriptory label of gauge + pub title: String, + /// Address of contract to serve gauge-specific info (AdapterQueryMsg) + pub adapter: Addr, + /// Frequency (in seconds) the gauge executes messages, typically something like 7*86400 + pub epoch: u64, + /// Minimum percentage of votes needed by a given option to be in the selected set + pub min_percent_selected: Option, + /// Maximum number of Options to make the selected set. Needed even with + /// `min_percent_selected` to provide some guarantees on gas usage of this query. + pub max_options_selected: u32, + // Any votes above that percentage will be discarded + pub max_available_percentage: Option, + /// True if the gauge is stopped + pub is_stopped: bool, + /// UNIX time (seconds) when next epoch can be executed. If < env.block.time then Execute can be called + pub next_epoch: u64, + /// The last set of options selected by the gauge, `None` before the first execution + pub last_executed_set: Option>, + /// Set this in migration if the gauge should be periodically reset + pub reset: Option, +} + +#[cw_serde] +pub struct Reset { + /// until the first reset, this is None - needed for 0-cost migration from current state + pub last: Option, + /// seconds between reset + pub reset_each: u64, + /// next time we can reset + pub next: u64, +} + +impl Gauge { + /// Returns `true` if the gauge is currently being reset + pub fn is_resetting(&self) -> bool { + self.reset + .as_ref() + .map(|r| r.last == Some(r.next)) + .unwrap_or_default() + } +} + +#[cw_serde] +pub struct WeightedVotes { + /// The gauge these votes are for + pub gauge_id: GaugeId, + /// The voting power behind the vote. + pub power: Uint128, + /// the user's votes for this gauge + pub votes: Vec, + /// Timestamp when vote was cast. + /// Allow `None` for 0-cost migration from current data + pub cast: Option, +} + +impl WeightedVotes { + /// Returns `true` if the vote is + pub fn is_expired(&self, gauge: &Gauge) -> bool { + // check if the vote is older than the last reset + match &gauge.reset { + Some(Reset { + last: Some(expired), + .. + }) => { + // votes with no timestamp are always considered too old once a reset happened + // (they are legacy votes pre-first reset) + self.cast.unwrap_or_default() < *expired + } + // everything is valid before the first reset (last = `None`) or if the gauge is not resettable + _ => false, + } + } +} + +impl Default for WeightedVotes { + fn default() -> Self { + WeightedVotes { + gauge_id: 0, + power: Uint128::zero(), + votes: vec![], + cast: None, + } + } +} + +#[cw_serde] +pub struct Vote { + /// Option voted for. + pub option: String, + /// The weight of the power given to this vote + pub weight: Decimal, +} + +struct VoteIndexes<'a> { + // Last type param defines the pk deserialization type + pub vote: MultiIndex<'a, GaugeId, WeightedVotes, (&'a Addr, GaugeId)>, +} + +impl<'a> IndexList for VoteIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + Box::new(std::iter::once(&self.vote as &dyn Index)) + } +} + +pub struct Votes<'a> { + // Votes are indexed by `(addr, gauge_id, weight)` triplet + votes: IndexedMap<'a, (&'a Addr, GaugeId), WeightedVotes, VoteIndexes<'a>>, +} + +impl<'a> Votes<'a> { + pub fn new(storage_key: &'a str, vote_subkey: &'a str) -> Self { + let indexes = VoteIndexes { + vote: MultiIndex::new(|_, vote| vote.gauge_id, storage_key, vote_subkey), + }; + let votes = IndexedMap::new(storage_key, indexes); + Self { votes } + } + + pub fn save( + &self, + storage: &mut dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + vote: &WeightedVotes, + ) -> StdResult<()> { + self.votes.save(storage, (voter, gauge_id), vote) + } + + pub fn set_votes( + &self, + storage: &mut dyn Storage, + env: &Env, + voter: &'a Addr, + gauge_id: GaugeId, + votes: Vec, + power: impl Into, + ) -> StdResult<()> { + let power = power.into(); + self.votes.save( + storage, + (voter, gauge_id), + &WeightedVotes { + gauge_id, + power, + votes, + cast: Some(env.block.time.seconds()), + }, + ) + } + + pub fn remove_votes( + &self, + storage: &mut dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult<()> { + self.votes.remove(storage, (voter, gauge_id)) + } + + pub fn load( + &self, + storage: &dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult { + self.votes.load(storage, (voter, gauge_id)) + } + + pub fn may_load( + &self, + storage: &dyn Storage, + voter: &'a Addr, + gauge_id: GaugeId, + ) -> StdResult> { + self.votes.may_load(storage, (voter, gauge_id)) + } + + pub fn query_votes_by_voter( + &self, + deps: Deps, + voter_addr: &'a Addr, + start_after: Option, + limit: Option, + ) -> StdResult> { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + + self.votes + .prefix(voter_addr) + .range(deps.storage, start, None, Order::Ascending) + .map(|index| { + let (_, vote) = index?; + Ok(vote) + }) + .take(limit) + .collect() + } + + pub fn query_votes_by_gauge( + &self, + deps: Deps, + gauge_id: GaugeId, + start_after: Option, + limit: Option, + ) -> StdResult> { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let addr = maybe_addr(deps.api, start_after)?; + let start = addr.as_ref().map(|a| Bound::exclusive((a, gauge_id))); + + let gauge = GAUGES.load(deps.storage, gauge_id)?; + + self.votes + .idx + .vote + .prefix(gauge_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .filter(|r| match r { + Ok((_, v)) => !v.is_expired(&gauge), // filter out expired votes + Err(_) => true, // keep the error + }) + .map(|r| { + let ((voter, _gauge), votes) = r?; + Ok(VoteInfo { + voter: voter.into_string(), + votes: votes.votes, + cast: votes.cast, + }) + }) + // NIT: collect and into_iter is a bit inefficient... guess it was too complex/confusing otherwise, so fine + .collect() + } +} + +/// Total amount of votes in all options, used to calculate min percentage. +pub const TOTAL_CAST: Map = Map::new("total_power"); + +/// Count how many points each option has per gauge +pub const TALLY: Map<(GaugeId, &str), u128> = Map::new("tally"); +/// Sorted index of options by points, separated by gauge - data field is a placeholder +pub const OPTION_BY_POINTS: Map<(GaugeId, u128, &str), u8> = Map::new("tally_points"); + +/// Updates the tally for one option. +/// The first time a user votes, they get `{old_vote: 0, new_vote: power}` +/// If they change options, call old option with `{old_vote: power, new_vote: 0}` and new option with `{old_vote: 0, new_vote: power}` +/// If a user changes power (member update hook), call existing option with `{old_vote: old_power, new_vote: new_power}` +pub fn update_tally( + storage: &mut dyn Storage, + gauge: GaugeId, + option: &str, + old_vote: u128, + new_vote: u128, +) -> StdResult<()> { + update_tallies(storage, gauge, vec![(option, old_vote, new_vote)]) +} + +/// Completely removes the given option from the tally. +pub fn remove_tally(storage: &mut dyn Storage, gauge: GaugeId, option: &str) -> StdResult<()> { + let old_vote = TALLY.may_load(storage, (gauge, option))?; + + // update main index + TALLY.remove(storage, (gauge, option)); + + if let Some(old_vote) = old_vote { + let total_cast = TOTAL_CAST.may_load(storage, gauge)?.unwrap_or_default(); + // update total cast + TOTAL_CAST.save(storage, gauge, &(total_cast - old_vote))?; + + // update sorted index + OPTION_BY_POINTS.remove(storage, (gauge, old_vote, option)); + } + + Ok(()) +} + +/// Updates the tally for one option. +/// The first time a user votes, they get `{old_vote: 0, new_vote: power}` +/// If they change options, call old option with `{old_vote: power, new_vote: 0}` and new option with `{old_vote: 0, new_vote: power}` +/// If a user changes power (member update hook), call existing option with `{old_vote: old_power, new_vote: new_power}` +pub fn update_tallies( + storage: &mut dyn Storage, + gauge: GaugeId, + // (option, old, new) + updates: Vec<(&str, u128, u128)>, +) -> StdResult<()> { + let mut old_votes = 0u128; + let mut new_votes = 0u128; + + for (option, old_vote, new_vote) in updates { + old_votes += old_vote; + new_votes += new_vote; + + // get old and new values + let old_count = TALLY.may_load(storage, (gauge, option))?; + let count = old_count.unwrap_or_default() + new_vote - old_vote; + + // update main index + TALLY.save(storage, (gauge, option), &count)?; + + // delete old secondary index (if any) + if let Some(old) = old_count { + OPTION_BY_POINTS.remove(storage, (gauge, old, option)); + } + // add new secondary index + OPTION_BY_POINTS.save(storage, (gauge, count, option), &1u8)?; + } + + // update total count + let total = TOTAL_CAST.may_load(storage, gauge)?.unwrap_or_default(); + let total = total + new_votes - old_votes; + TOTAL_CAST.save(storage, gauge, &total) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::Order; + + use cosmwasm_std::testing::{mock_dependencies, mock_env}; + + const GAUGE: u64 = 2; + + /// Let's keep them all the same length for less surprising iteration + const OPTION1: &str = "one"; + const OPTION2: &str = "two"; + // make sure it is alphabetically last + const OPTION3: &str = "zzz"; + + // demonstate how to call update tally and how to query the tallies, + // both by pk and by secondary index + #[test] + fn update_tally_initial_votes_work() { + let mut mock_deps = mock_dependencies(); + let deps = mock_deps.as_mut(); + + update_tally(deps.storage, GAUGE, OPTION1, 0, 250).unwrap(); + update_tally(deps.storage, GAUGE, OPTION2, 0, 400).unwrap(); + update_tally(deps.storage, GAUGE, OPTION3, 0, 100).unwrap(); + + // data in some other tally shouldn't mix with this gauge + update_tally(deps.storage, 17, OPTION3, 0, 55).unwrap(); + update_tally(deps.storage, 16, OPTION1, 0, 123).unwrap(); + + // get all options with primary index (ordered by string value of option) + let options: Vec<_> = TALLY + .prefix(GAUGE) + .range(deps.storage, None, None, Order::Ascending) + .collect::>>() + .unwrap(); + let expected = vec![ + (OPTION1.to_string(), 250u128), + (OPTION2.to_string(), 400u128), + (OPTION3.to_string(), 100u128), + ]; + assert_eq!(options, expected); + + // get them by secondary index, top to bottom + let options: Vec<_> = OPTION_BY_POINTS + .sub_prefix(GAUGE) + .keys(deps.storage, None, None, Order::Descending) + .collect::>>() + .unwrap(); + let expected = vec![ + (400u128, OPTION2.to_string()), + (250u128, OPTION1.to_string()), + (100u128, OPTION3.to_string()), + ]; + assert_eq!(options, expected); + + // total is properly set + let total = TOTAL_CAST.load(deps.storage, GAUGE).unwrap(); + assert_eq!(total, 750u128); + } + + fn to_vote_info(voter: &Addr, votes: &[Vote], cast: impl Into>) -> VoteInfo { + VoteInfo { + voter: voter.to_string(), + votes: votes.to_vec(), + cast: cast.into(), + } + } + + #[test] + fn votes_works() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let votes = votes(); + + // setup gauges + for gauge_id in 1..=3 { + GAUGES + .save( + &mut deps.storage, + gauge_id, + &Gauge { + title: "test".to_string(), + adapter: Addr::unchecked("gauge_adapter"), + epoch: 100, + min_percent_selected: None, + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: env.block.time.seconds(), + last_executed_set: None, + reset: None, + }, + ) + .unwrap(); + } + + let user1 = Addr::unchecked("user1"); + let votes1 = vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote1 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(3), + votes: votes1.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 1), &vote1) + .unwrap(); + + let user2 = Addr::unchecked("user2"); + let votes2 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(50), + }]; + let vote2 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(6), + votes: votes2.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user2, 1), &vote2) + .unwrap(); + + let user3 = Addr::unchecked("user3"); + let votes3 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(70), + }]; + let vote3 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(9), + votes: votes3.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user3, 1), &vote3) + .unwrap(); + + let votes4 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(75), + }]; + let vote4 = WeightedVotes { + gauge_id: 2, + power: Uint128::new(12), + votes: votes4, + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 2), &vote4) + .unwrap(); + + let votes5 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote5 = WeightedVotes { + gauge_id: 3, + power: Uint128::new(15), + votes: votes5, + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 3), &vote5) + .unwrap(); + + let vote_result = votes.votes.load(&deps.storage, (&user2, 1)).unwrap(); + assert_eq!(vote_result, vote2); + + let result = votes + .query_votes_by_gauge(deps.as_ref(), 1, None, None) + .unwrap(); + assert_eq!( + result, + vec![ + to_vote_info(&user1, &votes1, env.block.time.seconds()), + to_vote_info(&user2, &votes2, env.block.time.seconds()), + to_vote_info(&user3, &votes3, env.block.time.seconds()), + ] + ); + + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, None, None) + .unwrap(); + assert_eq!(result, vec![vote1, vote4, vote5]); + } + + #[test] + fn query_votes_by_gauge_paginated() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let votes = votes(); + + let gauge_id = 1; + + GAUGES + .save( + &mut deps.storage, + gauge_id, + &Gauge { + title: "test".to_string(), + adapter: Addr::unchecked("gauge_adapter"), + epoch: 100, + min_percent_selected: None, + max_options_selected: 10, + max_available_percentage: None, + is_stopped: false, + next_epoch: env.block.time.seconds(), + last_executed_set: None, + reset: None, + }, + ) + .unwrap(); + + let user1 = Addr::unchecked("user1"); + let votes1 = vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }]; + let vote1 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(3), + votes: votes1.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 1), &vote1) + .unwrap(); + + let user2 = Addr::unchecked("user2"); + let votes2 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(50), + }]; + let vote2 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(6), + votes: votes2.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user2, 1), &vote2) + .unwrap(); + + let user3 = Addr::unchecked("user3"); + let votes3 = vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(70), + }]; + let vote3 = WeightedVotes { + gauge_id: 1, + power: Uint128::new(9), + votes: votes3.clone(), + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user3, 1), &vote3) + .unwrap(); + + // limit to 2 results + let result = votes + .query_votes_by_gauge(deps.as_ref(), gauge_id, None, Some(2)) + .unwrap(); + assert_eq!( + result, + vec![ + to_vote_info(&user1, &votes1, env.block.time.seconds()), + to_vote_info(&user2, &votes2, env.block.time.seconds()) + ] + ); + + // start from second user (start_after user1) + let result = votes + .query_votes_by_gauge(deps.as_ref(), gauge_id, Some(user1.to_string()), None) + .unwrap(); + assert_eq!( + result, + vec![ + to_vote_info(&user2, &votes2, env.block.time.seconds()), + to_vote_info(&user3, &votes3, env.block.time.seconds()) + ] + ); + } + + #[test] + fn query_votes_by_user_paginated() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let votes = votes(); + let user1 = Addr::unchecked("user1"); + + let vote1 = WeightedVotes { + gauge_id: 2, + power: Uint128::new(3), + votes: vec![Vote { + option: "someoption".to_owned(), + weight: Decimal::percent(100), + }], + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 2), &vote1) + .unwrap(); + + let vote2 = WeightedVotes { + gauge_id: 3, + power: Uint128::new(6), + votes: vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }], + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 3), &vote2) + .unwrap(); + + let vote3 = WeightedVotes { + gauge_id: 4, + power: Uint128::new(9), + votes: vec![Vote { + option: "otheroption".to_owned(), + weight: Decimal::percent(100), + }], + cast: Some(env.block.time.seconds()), + }; + votes + .votes + .save(&mut deps.storage, (&user1, 4), &vote3) + .unwrap(); + + // limit to 2 results + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, None, Some(2)) + .unwrap(); + assert_eq!(result, vec![vote1, vote2.clone()]); + + // start from second user (start_after gauge_id 2) + let result = votes + .query_votes_by_voter(deps.as_ref(), &user1, Some(2), None) + .unwrap(); + assert_eq!(result, vec![vote2, vote3]); + } +} From b37a61f181e5842dbe3bd83a1f307622320bf86b Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Thu, 4 Jul 2024 19:10:48 -0400 Subject: [PATCH 07/12] Support all DAO types for gauges --- contracts/gauges/gauge-adapter/README.md | 5 +- .../gauges/gauge-adapter/src/contract.rs | 1 + contracts/gauges/gauge/README.md | 47 +- contracts/gauges/gauge/src/contract.rs | 203 +++++- contracts/gauges/gauge/src/msg.rs | 14 +- contracts/gauges/gauge/src/multitest/suite.rs | 97 ++- .../gauges/gauge/src/multitest/voting.rs | 591 +++++++++++++++++- contracts/gauges/gauge/src/state.rs | 4 +- 8 files changed, 919 insertions(+), 43 deletions(-) diff --git a/contracts/gauges/gauge-adapter/README.md b/contracts/gauges/gauge-adapter/README.md index 46b14b9bb..a1fcf92e1 100644 --- a/contracts/gauges/gauge-adapter/README.md +++ b/contracts/gauges/gauge-adapter/README.md @@ -1,6 +1,8 @@ # Marketing Gauge Adapter Contract -This is an adapter contract for use in conjunction with the [WYND gauge contract](https://github.com/cosmorama/wynddao/tree/main/contracts/gauge). The purpose of this adapter is to allow people related to marketing to apply for a reward. The total reward amount is set during contract instantiation and will be divided among applicants based on community votes. +This is an adapter contract for use in conjunction with the [gauge contract](../gauge/README.md). + +The purpose of this adapter is to allow people to apply for a reward. The total reward amount is set during contract instantiation and will be divided among applicants based on community votes. ## Implementation @@ -11,6 +13,7 @@ CreateSubmission { name: String, url: String, address: String, +} ``` Depending on how the gauge contract is instantiated a spam preventing deposit can be required to create a submission. This is specified by the field `required_deposit` in the `Config` structure. diff --git a/contracts/gauges/gauge-adapter/src/contract.rs b/contracts/gauges/gauge-adapter/src/contract.rs index 3f7bc5bc4..a53cab416 100644 --- a/contracts/gauges/gauge-adapter/src/contract.rs +++ b/contracts/gauges/gauge-adapter/src/contract.rs @@ -56,6 +56,7 @@ pub fn execute( match msg { ExecuteMsg::Receive(msg) => receive_cw20_message(deps, info, msg), ExecuteMsg::CreateSubmission { name, url, address } => { + // TODO this is very hacky let received = info.funds.into_iter().next().unwrap_or_default(); execute::create_submission( deps, diff --git a/contracts/gauges/gauge/README.md b/contracts/gauges/gauge/README.md index 953db48e7..f813776fe 100644 --- a/contracts/gauges/gauge/README.md +++ b/contracts/gauges/gauge/README.md @@ -1,10 +1,14 @@ # Gauge Orchestrator Contract There are many places where we want something like a [gauge contract](https://resources.curve.fi/reward-gauges/gauge-weights), -when we need to select a weighted group out of a larger group of options. For example, we will need one to -select the validators that the staking derivatives delegates to, and relative percentages to which one. -We will need one to select which AMM Pools should receive how many incentives (both JUNO and possibly WYND). -We may need one to assign incentives on our lending protocol. +when we need to select a weighted group out of a larger group of options. + +## TODO +- [ ] More tests to make sure it works with different DAO types (Membership, NFT staking, and Token Staking DAOs) +- [ ] Make improvements to `GaugeAdapter` API +- [ ] Improve `GaugeAdapter` examples +- [ ] Does not handle small numbers elegantly (a problem with NFT DAOs, where people might stake 1 NFT) +- [ ] Better READMEs ## Orchestrator @@ -14,7 +18,7 @@ voting power changes, updating their vote weight in the gauge, and the tally for had voted for (if any). Every contract call has some overhead, which is silently added to the basic staking action. -If we have 5 gauges in WYND DAO, we would likely have a minimum of 5 x 65k or 325k gas per staking action, +If we have 5 gauges in a DAO, we would likely have a minimum of 5 x 65k or 325k gas per staking action, just to update gauges. This is a lot of overhead, and we want to avoid it. To do so, we make one "Gauge Orchestrator", which can manage many different gauges. They all have the @@ -22,7 +26,7 @@ same voting logic and rules to update when the voting power changes. The Orchest contract that must be called by the staking contract, and doing a few writes for each gauge is a lot cheaper gas-wise than calling a separate contract. -The Orchestrator has an "owner" (the WYND DAO) which is responsible for adding new gauges here, +The Orchestrator has an "owner" (the DAO) which is responsible for adding new gauges here, and eventually stopping them if we don't need them anymore (to avoid extra writes). ## Gauge Functionality @@ -30,8 +34,8 @@ and eventually stopping them if we don't need them anymore (to avoid extra write A gauge is initialised with a set of options. Anyone with voting power may vote for any option at any time, which is recorded, and also updates the tally. If they revote, it checks their last vote to reduce power on that before adding to the new one. When an "update hook" is triggered, it updates the voting power -of that user's vote, while maintaining the same option. Either increasing or decreasing the tally -for the given option as appropriate. +of that user's vote, while maintaining the same option. Either increasing or decreasing the tally +for the given option as appropriate. Every epoch (eg 1/week), the current tally of the gauge is sampled, and some cut-off applies (top 20, min 0.5% of votes, etc). The resulting set is the "selected set" and the options along with @@ -42,6 +46,7 @@ action (eg. distribute reward tokens). We will be using one Orchestrator for many different gauges that update many different contracts. To make it more extensible, we define option as an arbitrary string that makes sense to that contract. + We also store the integration logic in an external contract, called a `GaugeAdapter` that must provide 3 queries to the Orchestrator: @@ -51,34 +56,20 @@ We also store the integration logic in an external contract, called a `GaugeAdap * Create update messages: Accepts "selected set" as argument, returns `Vec` to be executed by the gauge contract / DAO. -### Adapters - -We will create a mock implementation of an Adapter for testing. - -In production, we will need one for that queries an AMM Factory for open pools, -and knows how to send the rewards to the appropriate pools. - -We will need another for a JUNO staking derivative, to select which validators should -be in the set, and then upon execute, inform the contract to delegate to those validators. - -We would need a modified one, that uses IBC packets, for a remote staking derivative. -It would need to query the remote chain for the set of validators. And the messages -would be ICA Packets to transmit the new set to the remote chain. - -As you can see, it should be a quite flexible design, while keeping the tallying logic -centralized here and minimal gas impact on the staking contract to track the multiple gauges.s +We will have a mock implementation of an Adapter for testing. ## Example Use When the DAO wants to add another gauge, it first uploads the code for generating eg. AMM reward messages, -and instantiates a properly configured Adapter. Then, it votes to create a new Gauge that uses this adapter. -Upon creating the gauge, it will query the adapter for the current set of options to initialize state. +and instantiates a properly configured Adapter. + +Then, it votes to create a new Gauge that uses this adapter. Upon creating the gauge, it will query the adapter +for the current set of options to initialize state. After one epoch has passed, anyone can trigger `Execute` on this gauge ID, and the Orchestrator will apply the logic to determine the "selected set". It will then query the adapter for the messages needed to convert that selection into the appropriate action, and it will send those to the -[WYND DAO core module](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#the-core-module) -to be executed. +DAO DAO core module to be executed. ## Storage diff --git a/contracts/gauges/gauge/src/contract.rs b/contracts/gauges/gauge/src/contract.rs index 0977716d3..20ca4faec 100644 --- a/contracts/gauges/gauge/src/contract.rs +++ b/contracts/gauges/gauge/src/contract.rs @@ -36,9 +36,11 @@ pub fn instantiate( set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; let voting_powers = deps.api.addr_validate(&msg.voting_powers)?; + let hook_caller = deps.api.addr_validate(&msg.hook_caller)?; let owner = deps.api.addr_validate(&msg.owner)?; let config = Config { voting_powers, + hook_caller, owner, dao_core: info.sender, }; @@ -62,6 +64,8 @@ pub fn execute( msg: ExecuteMsg, ) -> Result { match msg { + ExecuteMsg::StakeChangeHook(msg) => execute::stake_changed(deps, info, msg), + ExecuteMsg::NftStakeChangeHook(msg) => execute::nft_stake_changed(deps, info, msg), ExecuteMsg::MemberChangedHook(hook_msg) => { execute::member_changed(deps, info.sender, hook_msg.diffs) } @@ -100,6 +104,7 @@ pub fn execute( mod execute { use cw4::MemberDiff; + use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; use super::*; use crate::state::{remove_tally, update_tallies, Reset, Vote}; @@ -110,8 +115,8 @@ mod execute { sender: Addr, diffs: Vec, ) -> Result { - // make sure only voting powers contract can activate this endpoint - if sender != CONFIG.load(deps.storage)?.voting_powers { + // make sure only hook caller contract can activate this endpoint + if sender != CONFIG.load(deps.storage)?.hook_caller { return Err(ContractError::Unauthorized {}); } @@ -163,6 +168,198 @@ mod execute { Ok(response) } + pub fn stake_changed( + deps: DepsMut, + info: MessageInfo, + msg: StakeChangedHookMsg, + ) -> Result { + // make sure only hook caller contract can activate this endpoint + if info.sender != CONFIG.load(deps.storage)?.hook_caller { + return Err(ContractError::Unauthorized {}); + } + + match msg { + StakeChangedHookMsg::Stake { addr, amount } => { + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in votes().query_votes_by_voter( + deps.as_ref(), + &addr, + None, + Some(query::MAX_LIMIT), + )? { + let gauge = GAUGES.load(deps.storage, vote.gauge_id)?; + + let old = vote.power; + + // Voting power increases with staking amount + let new = vote.power + amount; + + if vote.is_expired(&gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // Update and store new vote power for this user + vote.power = new; + votes().save(deps.storage, &addr, vote.gauge_id, &vote)?; + } + + Ok(Response::new()) + } + StakeChangedHookMsg::Unstake { addr, amount } => { + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in votes().query_votes_by_voter( + deps.as_ref(), + &addr, + None, + Some(query::MAX_LIMIT), + )? { + let gauge = GAUGES.load(deps.storage, vote.gauge_id)?; + + let old = vote.power; + + // Decrease voting power by unstaked amount + let new = vote.power - amount; + + if vote.is_expired(&gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // Update and store new vote power for this user + vote.power = new; + votes().save(deps.storage, &addr, vote.gauge_id, &vote)?; + } + + Ok(Response::new()) + } + } + } + + pub fn nft_stake_changed( + deps: DepsMut, + info: MessageInfo, + msg: NftStakeChangedHookMsg, + ) -> Result { + // make sure only hook caller contract can activate this endpoint + if info.sender != CONFIG.load(deps.storage)?.hook_caller { + return Err(ContractError::Unauthorized {}); + } + + match msg { + NftStakeChangedHookMsg::Stake { addr, token_id: _ } => { + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in votes().query_votes_by_voter( + deps.as_ref(), + &addr, + None, + Some(query::MAX_LIMIT), + )? { + let gauge = GAUGES.load(deps.storage, vote.gauge_id)?; + + let old = vote.power; + // Voting power increases by one (only one token_id staked at a time) + let new = vote.power + Uint128::one(); + + if vote.is_expired(&gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // Update and store new vote power for this user + vote.power = new; + votes().save(deps.storage, &addr, vote.gauge_id, &vote)?; + } + + Ok(Response::new()) + } + NftStakeChangedHookMsg::Unstake { addr, token_ids } => { + // for each gauge this user voted on, + // update the tallies and update the users vote power + for mut vote in votes().query_votes_by_voter( + deps.as_ref(), + &addr, + None, + Some(query::MAX_LIMIT), + )? { + let gauge = GAUGES.load(deps.storage, vote.gauge_id)?; + + let old = vote.power; + + // Decrease voting power by number of token_ids + let amount: u128 = token_ids.len().try_into().unwrap(); + let new = vote.power - Uint128::new(amount); + + if vote.is_expired(&gauge) { + continue; + } + + // calculate updates and adjust tallies + let updates: Vec<_> = vote + .votes + .iter() + .map(|v| { + ( + v.option.as_str(), + (old * v.weight).u128(), + (new * v.weight).u128(), + ) + }) + .collect(); + update_tallies(deps.storage, vote.gauge_id, updates)?; + + // Update and store new vote power for this user + vote.power = new; + votes().save(deps.storage, &addr, vote.gauge_id, &vote)?; + } + + Ok(Response::new()) + } + } + } + pub fn create_gauge( deps: DepsMut, env: Env, @@ -385,6 +582,8 @@ mod execute { .add_attribute("gauge_id", gauge_id.to_string())) } + // TODO this doesn't seem very safe... double check permissions here + // Why is check option optional? pub fn add_option( deps: DepsMut, sender: Addr, diff --git a/contracts/gauges/gauge/src/msg.rs b/contracts/gauges/gauge/src/msg.rs index 468590361..3578e32ec 100644 --- a/contracts/gauges/gauge/src/msg.rs +++ b/contracts/gauges/gauge/src/msg.rs @@ -1,16 +1,18 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::{CosmosMsg, Decimal, Uint128}; use cw4::MemberChangedHookMsg; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; use crate::state::{Reset, Vote}; -// use wynd_stake::hook::MemberChangedHookMsg; type GaugeId = u64; #[cw_serde] pub struct InstantiateMsg { - /// Address of contract to that contains all voting powers (where we query and listen to hooks) + /// Address of contract to that contains all voting powers (where we query) pub voting_powers: String, + /// Addres that will call voting power change hooks (often same as voting power contract) + pub hook_caller: String, /// Address that can add new gauges or stop them pub owner: String, /// Allow attaching multiple adaptors during instantiation. @@ -41,9 +43,11 @@ pub struct GaugeConfig { #[cw_serde] pub enum ExecuteMsg { - /// Must be compatible with MemberChangedExecuteMsg from wynd-stake. - /// Use this to update - /// TODO fix me! + /// Updates gauge voting power in Token DAOs when a user stakes or unstakes + StakeChangeHook(StakeChangedHookMsg), + /// Updates gauge voting power in NFT DAOs when a user stakes or unstakes + NftStakeChangeHook(NftStakeChangedHookMsg), + /// Updates gauge voting power for membership changes MemberChangedHook(MemberChangedHookMsg), /// This creates a new Gauge, returns CreateGaugeReply JSON-encoded in the data field. /// Can only be called by owner diff --git a/contracts/gauges/gauge/src/multitest/suite.rs b/contracts/gauges/gauge/src/multitest/suite.rs index d6b695e8c..de16b1d32 100644 --- a/contracts/gauges/gauge/src/multitest/suite.rs +++ b/contracts/gauges/gauge/src/multitest/suite.rs @@ -1,9 +1,9 @@ use anyhow::Result as AnyResult; - use cosmwasm_std::{ coin, to_json_binary, Addr, Coin, CosmosMsg, Decimal, StdResult, Uint128, WasmMsg, }; use cw4::Member; +use cw4_group::msg::ExecuteMsg as Cw4ExecuteMsg; use cw_multi_test::{App, AppResponse, ContractWrapper, Executor}; use cw_utils::Duration; use dao_interface::{ @@ -24,7 +24,7 @@ use dao_voting::{ threshold::{PercentageThreshold, Threshold}, voting::Vote, }; -use dao_voting_cw4::msg::InstantiateMsg as VotingInstantiateMsg; +use dao_voting_cw4::msg::{InstantiateMsg as VotingInstantiateMsg, QueryMsg as VotingQueryMsg}; use super::adapter::{ contract as adapter_contract, ExecuteMsg as AdapterExecuteMsg, @@ -212,6 +212,10 @@ impl SuiteBuilder { .wrap() .query_wasm_smart(&core, &CoreQueryMsg::VotingModule {}) .unwrap(); + let group_contract: Addr = app + .wrap() + .query_wasm_smart(voting_contract.clone(), &VotingQueryMsg::GroupContract {}) + .unwrap(); let proposal_single_contract: Vec = app .wrap() .query_wasm_smart( @@ -230,6 +234,7 @@ impl SuiteBuilder { owner: owner.to_string(), app, core, + group_contract, voting: voting_contract, proposal_single: proposal_single_contract[0].address.clone(), gauge_code_id, @@ -242,10 +247,11 @@ pub struct Suite { pub owner: String, pub app: App, pub core: Addr, - voting: Addr, - proposal_single: Addr, + pub group_contract: Addr, + pub voting: Addr, + pub proposal_single: Addr, pub gauge_code_id: u64, - gauge_adapter_code_id: u64, + pub gauge_adapter_code_id: u64, } impl Suite { @@ -511,6 +517,48 @@ impl Suite { code_id: self.gauge_code_id, msg: to_json_binary(&InstantiateMsg { voting_powers: self.voting.to_string(), + hook_caller: self.group_contract.to_string(), + owner: self.owner.clone(), + gauges: gauge_config.into(), + })?, + admin: Some(Admin::Address { + addr: self.owner.clone(), + }), + label: "CW4 Voting Contract".to_owned(), + funds: vec![], + }], + to_disable: vec![], + })?, + funds: vec![], + })], + proposer: None, + vote: None, + }); + self.app.execute_contract( + Addr::unchecked(proposer), + self.proposal_single.clone(), + &propose_msg, + &[], + ) + } + + pub fn propose_update_proposal_module_custom_hook_caller( + &mut self, + proposer: impl Into, + hook_caller: impl Into, + gauge_config: impl Into>>, + ) -> AnyResult { + let propose_msg = ProposalSingleExecuteMsg::Propose(SingleChoiceProposeMsg { + title: "gauge as proposal module".to_owned(), + description: "Propose core to set gauge as proposal module".to_owned(), + msgs: vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: self.core.to_string(), + msg: to_json_binary(&CoreExecuteMsg::UpdateProposalModules { + to_add: vec![ModuleInstantiateInfo { + code_id: self.gauge_code_id, + msg: to_json_binary(&InstantiateMsg { + voting_powers: self.voting.to_string(), + hook_caller: Addr::unchecked(hook_caller).to_string(), owner: self.owner.clone(), gauges: gauge_config.into(), })?, @@ -535,6 +583,32 @@ impl Suite { ) } + pub fn propose_add_membership_change_hook( + &mut self, + proposer: impl Into, + gauge_contract: Addr, + ) -> AnyResult { + let propose_msg = ProposalSingleExecuteMsg::Propose(SingleChoiceProposeMsg { + title: "Add membership change hook".to_owned(), + description: "Propose core to add membership change hook for gauge".to_owned(), + msgs: vec![CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: self.group_contract.to_string(), + msg: to_json_binary(&Cw4ExecuteMsg::AddHook { + addr: gauge_contract.to_string(), + })?, + funds: vec![], + })], + proposer: None, + vote: None, + }); + self.app.execute_contract( + Addr::unchecked(proposer), + self.proposal_single.clone(), + &propose_msg, + &[], + ) + } + pub fn instantiate_adapter_and_create_gauge( &mut self, gauge_contract: Addr, @@ -660,6 +734,19 @@ impl Suite { ) } + pub fn force_update_members( + &mut self, + remove: Vec, + add: Vec, + ) -> AnyResult { + self.app.execute_contract( + Addr::unchecked(self.core.clone()), + self.group_contract.clone(), + &Cw4ExecuteMsg::UpdateMembers { remove, add }, + &[], + ) + } + pub fn list_proposals(&self) -> StdResult> { let list: ProposalListResponse = self.app.wrap().query_wasm_smart( self.proposal_single.clone(), diff --git a/contracts/gauges/gauge/src/multitest/voting.rs b/contracts/gauges/gauge/src/multitest/voting.rs index c1e934666..962daa1ac 100644 --- a/contracts/gauges/gauge/src/multitest/voting.rs +++ b/contracts/gauges/gauge/src/multitest/voting.rs @@ -1,4 +1,8 @@ -use cosmwasm_std::{Decimal, Uint128}; +use cosmwasm_std::{Addr, Decimal, Uint128}; +use cw4::Member; +use cw_multi_test::Executor; +use dao_hooks::nft_stake::{NftStakeChangedExecuteMsg, NftStakeChangedHookMsg}; +use dao_hooks::stake::StakeChangedExecuteMsg; use dao_voting::voting::Vote; use super::suite::SuiteBuilder; @@ -672,3 +676,588 @@ fn vote_for_max_capped_option() { ] ); } + +#[test] +fn membership_voting_power_change() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .with_core_balance((10000, "ujuno")) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module(voter1.to_string(), None) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + // Setup membership change hooks + suite + .propose_add_membership_change_hook(voter1.to_string(), gauge_contract.clone()) + .unwrap(); + let proposal = suite.list_proposals().unwrap()[1]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 90, suite.current_time()), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::new(90))]); + + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 90, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let pre_voter1_takeover_gauge_set = + suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Voter one's option is least popular + assert_eq!( + pre_voter1_takeover_gauge_set, + vec![ + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)), + ("voter1".to_string(), Uint128::new(90)) + ] + ); + + // Force update members, giving voter 1 more power + suite + .force_update_members( + vec![], + vec![Member { + addr: voter1.to_string(), + weight: 1000, + }], + ) + .unwrap(); + suite.next_block(); + + let current_gauge_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Currect selected set should be different than before voter1 got power + assert_ne!(pre_voter1_takeover_gauge_set, current_gauge_set); + + // Voter1 option is now most popular + assert_eq!( + current_gauge_set, + vec![ + ("voter1".to_string(), Uint128::new(900)), + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)) + ] + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // Force update members, kick out voter 1 + suite + .force_update_members(vec![voter1.to_string()], vec![]) + .unwrap(); + suite.next_block(); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let current_gauge_set = suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(); + + // Voter1 removed and so is the one thing they voted for + assert_eq!( + current_gauge_set, + Some(vec![ + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)) + ]) + ); +} + +#[test] +fn token_staking_voting_power_change() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let hook_caller = "token-staking-contract"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 100), (voter2, 200)]) + .with_core_balance((10000, "ujuno")) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module_custom_hook_caller( + voter1.to_string(), + hook_caller.to_string(), + None, + ) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(90))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 90, suite.current_time()), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::new(90))]); + + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 90, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let pre_voter1_takeover_gauge_set = + suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Voter one's option is least popular + assert_eq!( + pre_voter1_takeover_gauge_set, + vec![ + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)), + ("voter1".to_string(), Uint128::new(90)) + ] + ); + + // Use hook caller to mock voter1 staking + suite + .app + .execute_contract( + Addr::unchecked(hook_caller), + gauge_contract.clone(), + &StakeChangedExecuteMsg::StakeChangeHook( + dao_hooks::stake::StakeChangedHookMsg::Stake { + addr: Addr::unchecked(voter1), + amount: Uint128::new(900), + }, + ), + &[], + ) + .unwrap(); + + suite.next_block(); + + let current_gauge_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Currect selected set should be different than before voter1 got power + assert_ne!(pre_voter1_takeover_gauge_set, current_gauge_set); + + // Voter1 option is now most popular + assert_eq!( + current_gauge_set, + vec![ + ("voter1".to_string(), Uint128::new(900)), + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)) + ] + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // Mock voter 1 unstaking + suite + .app + .execute_contract( + Addr::unchecked(hook_caller), + gauge_contract.clone(), + &StakeChangedExecuteMsg::StakeChangeHook( + dao_hooks::stake::StakeChangedHookMsg::Unstake { + addr: Addr::unchecked(voter1), + amount: Uint128::new(1000), + }, + ), + &[], + ) + .unwrap(); + suite.next_block(); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let current_gauge_set = suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(); + + // Voter1 removed and so is the one thing they voted for + assert_eq!( + current_gauge_set, + Some(vec![ + ("option2".to_string(), Uint128::new(100)), + ("option1".to_string(), Uint128::new(100)) + ]) + ); +} + +#[test] +fn nft_staking_voting_power_change() { + let voter1 = "voter1"; + let voter2 = "voter2"; + let hook_caller = "nft-staking-contract"; + let mut suite = SuiteBuilder::new() + .with_voting_members(&[(voter1, 1), (voter2, 2)]) + .with_core_balance((10000, "ujuno")) + .build(); + + suite.next_block(); + suite + .propose_update_proposal_module_custom_hook_caller( + voter1.to_string(), + hook_caller.to_string(), + None, + ) + .unwrap(); + + suite.next_block(); + let proposal = suite.list_proposals().unwrap()[0]; + suite + .place_vote_single(voter1, proposal, Vote::Yes) + .unwrap(); + suite + .place_vote_single(voter2, proposal, Vote::Yes) + .unwrap(); + + suite.next_block(); + suite + .execute_single_proposal(voter1.to_string(), proposal) + .unwrap(); + let proposal_modules = suite.query_proposal_modules().unwrap(); + + let gauge_contract = proposal_modules[1].clone(); + + let gauge_adapter = suite + .instantiate_adapter_and_create_gauge( + gauge_contract.clone(), + &[voter1, voter2], + (1000, "ujuno"), + None, + None, + ) + .unwrap(); + let gauge_id = 0; // first created gauge + + // vote for option from adapter (voting members are by default + // options in adapter in this test suite) + suite + .place_votes( + &gauge_contract, + voter1.to_owned(), + gauge_id, + Some(vec![(voter1.to_owned(), Decimal::percent(100))]), + ) + .unwrap(); + assert_eq!( + simple_vote(voter1, voter1, 100, suite.current_time()), + suite + .query_vote(&gauge_contract, gauge_id, voter1) + .unwrap() + .unwrap(), + ); + // check tally is proper + let selected_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + assert_eq!(selected_set, vec![(voter1.to_string(), Uint128::one())]); + + // add new valid options to the gauge adapter + suite.add_valid_option(&gauge_adapter, "option1").unwrap(); + suite.add_valid_option(&gauge_adapter, "option2").unwrap(); + + // change vote for option added through gauge + suite + .add_option(&gauge_contract, voter1, gauge_id, "option1") + .unwrap(); + suite + .add_option(&gauge_contract, voter1, gauge_id, "option2") + .unwrap(); + // voter2 drops vote as well + suite + .place_votes( + &gauge_contract, + voter2.to_owned(), + gauge_id, + Some(vec![ + ("option1".to_owned(), Decimal::percent(50)), + ("option2".to_owned(), Decimal::percent(50)), + ]), + ) + .unwrap(); + assert_eq!( + vec![ + simple_vote(voter1, voter1, 100, suite.current_time()), + multi_vote( + voter2, + &[("option1", 50), ("option2", 50)], + suite.current_time() + ), + ], + suite.query_list_votes(&gauge_contract, gauge_id).unwrap() + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let pre_voter1_takeover_gauge_set = + suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Voter one's option is least popular + assert_eq!( + pre_voter1_takeover_gauge_set, + vec![ + ("voter1".to_string(), Uint128::new(1)), + ("option2".to_string(), Uint128::new(1)), + ("option1".to_string(), Uint128::new(1)), + ] + ); + + // Mock voter 1 staking NFT + suite + .app + .execute_contract( + Addr::unchecked(hook_caller), + gauge_contract.clone(), + &NftStakeChangedExecuteMsg::NftStakeChangeHook(NftStakeChangedHookMsg::Stake { + addr: Addr::unchecked(voter1), + token_id: "1".to_string(), + }), + &[], + ) + .unwrap(); + + suite.next_block(); + + let current_gauge_set = suite.query_selected_set(&gauge_contract, gauge_id).unwrap(); + + // Currect selected set should be different than before voter1 got power + assert_ne!(pre_voter1_takeover_gauge_set, current_gauge_set); + + // Voter1 option is now most popular + assert_eq!( + current_gauge_set, + vec![ + ("voter1".to_string(), Uint128::new(2)), + ("option2".to_string(), Uint128::new(1)), + ("option1".to_string(), Uint128::new(1)) + ] + ); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + // Mock voter1 unstaking 2 nfts + suite + .app + .execute_contract( + Addr::unchecked(hook_caller), + gauge_contract.clone(), + &NftStakeChangedExecuteMsg::NftStakeChangeHook(NftStakeChangedHookMsg::Unstake { + addr: Addr::unchecked(voter1), + token_ids: vec!["1".to_string(), "2".to_string()], + }), + &[], + ) + .unwrap(); + suite.next_block(); + + // Execute after epoch passes + suite.advance_time(EPOCH); + suite + .execute_options(&gauge_contract, voter1, gauge_id) + .unwrap(); + + let current_gauge_set = suite + .query_last_executed_set(&gauge_contract, gauge_id) + .unwrap(); + + // Voter1 removed and so is the one thing they voted for + assert_eq!( + current_gauge_set, + Some(vec![ + ("option2".to_string(), Uint128::new(1)), + ("option1".to_string(), Uint128::new(1)) + ]) + ); +} diff --git a/contracts/gauges/gauge/src/state.rs b/contracts/gauges/gauge/src/state.rs index 30ad5a974..a48c4fd20 100644 --- a/contracts/gauges/gauge/src/state.rs +++ b/contracts/gauges/gauge/src/state.rs @@ -33,8 +33,10 @@ const DEFAULT_LIMIT: u32 = 30; #[cw_serde] pub struct Config { - /// Address of contract to that contains all voting powers (where we query and listen to hooks) + /// Address of contract to that contains all voting powers (where we query) pub voting_powers: Addr, + /// Addres that will call voting power change hooks (often same as voting power contract) + pub hook_caller: Addr, /// Address that can add new gauges or stop them pub owner: Addr, /// Address of DAO core module resposible for instantiation and execution of messages From c4194f9a81bae0499a42303b9abaebeb9c68b6df Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Thu, 4 Jul 2024 19:29:26 -0400 Subject: [PATCH 08/12] Make gauges folder --- contracts/{ => gauges}/gauge/.cargo/config | 0 contracts/{ => gauges}/gauge/Cargo.toml | 0 contracts/{ => gauges}/gauge/README.md | 0 contracts/{ => gauges}/gauge/src/bin/schema.rs | 0 contracts/{ => gauges}/gauge/src/contract.rs | 0 contracts/{ => gauges}/gauge/src/error.rs | 0 contracts/{ => gauges}/gauge/src/helpers.rs | 0 contracts/{ => gauges}/gauge/src/lib.rs | 0 contracts/{ => gauges}/gauge/src/msg.rs | 0 contracts/{ => gauges}/gauge/src/multitest/adapter.rs | 0 contracts/{ => gauges}/gauge/src/multitest/gauge.rs | 0 contracts/{ => gauges}/gauge/src/multitest/mod.rs | 0 contracts/{ => gauges}/gauge/src/multitest/reset.rs | 0 contracts/{ => gauges}/gauge/src/multitest/suite.rs | 0 contracts/{ => gauges}/gauge/src/multitest/tally.rs | 0 contracts/{ => gauges}/gauge/src/multitest/voting.rs | 0 contracts/{ => gauges}/gauge/src/state.rs | 0 contracts/{ => gauges}/marketing-gauge-adapter/.cargo/config | 0 contracts/{ => gauges}/marketing-gauge-adapter/Cargo.toml | 0 contracts/{ => gauges}/marketing-gauge-adapter/README.md | 0 contracts/{ => gauges}/marketing-gauge-adapter/src/bin/schema.rs | 0 contracts/{ => gauges}/marketing-gauge-adapter/src/contract.rs | 0 contracts/{ => gauges}/marketing-gauge-adapter/src/error.rs | 0 contracts/{ => gauges}/marketing-gauge-adapter/src/lib.rs | 0 contracts/{ => gauges}/marketing-gauge-adapter/src/msg.rs | 0 .../{ => gauges}/marketing-gauge-adapter/src/multitest/mod.rs | 0 .../{ => gauges}/marketing-gauge-adapter/src/multitest/options.rs | 0 .../marketing-gauge-adapter/src/multitest/submission.rs | 0 .../{ => gauges}/marketing-gauge-adapter/src/multitest/suite.rs | 0 contracts/{ => gauges}/marketing-gauge-adapter/src/state.rs | 0 30 files changed, 0 insertions(+), 0 deletions(-) rename contracts/{ => gauges}/gauge/.cargo/config (100%) rename contracts/{ => gauges}/gauge/Cargo.toml (100%) rename contracts/{ => gauges}/gauge/README.md (100%) rename contracts/{ => gauges}/gauge/src/bin/schema.rs (100%) rename contracts/{ => gauges}/gauge/src/contract.rs (100%) rename contracts/{ => gauges}/gauge/src/error.rs (100%) rename contracts/{ => gauges}/gauge/src/helpers.rs (100%) rename contracts/{ => gauges}/gauge/src/lib.rs (100%) rename contracts/{ => gauges}/gauge/src/msg.rs (100%) rename contracts/{ => gauges}/gauge/src/multitest/adapter.rs (100%) rename contracts/{ => gauges}/gauge/src/multitest/gauge.rs (100%) rename contracts/{ => gauges}/gauge/src/multitest/mod.rs (100%) rename contracts/{ => gauges}/gauge/src/multitest/reset.rs (100%) rename contracts/{ => gauges}/gauge/src/multitest/suite.rs (100%) rename contracts/{ => gauges}/gauge/src/multitest/tally.rs (100%) rename contracts/{ => gauges}/gauge/src/multitest/voting.rs (100%) rename contracts/{ => gauges}/gauge/src/state.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/.cargo/config (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/Cargo.toml (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/README.md (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/bin/schema.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/contract.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/error.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/lib.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/msg.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/multitest/mod.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/multitest/options.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/multitest/submission.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/multitest/suite.rs (100%) rename contracts/{ => gauges}/marketing-gauge-adapter/src/state.rs (100%) diff --git a/contracts/gauge/.cargo/config b/contracts/gauges/gauge/.cargo/config similarity index 100% rename from contracts/gauge/.cargo/config rename to contracts/gauges/gauge/.cargo/config diff --git a/contracts/gauge/Cargo.toml b/contracts/gauges/gauge/Cargo.toml similarity index 100% rename from contracts/gauge/Cargo.toml rename to contracts/gauges/gauge/Cargo.toml diff --git a/contracts/gauge/README.md b/contracts/gauges/gauge/README.md similarity index 100% rename from contracts/gauge/README.md rename to contracts/gauges/gauge/README.md diff --git a/contracts/gauge/src/bin/schema.rs b/contracts/gauges/gauge/src/bin/schema.rs similarity index 100% rename from contracts/gauge/src/bin/schema.rs rename to contracts/gauges/gauge/src/bin/schema.rs diff --git a/contracts/gauge/src/contract.rs b/contracts/gauges/gauge/src/contract.rs similarity index 100% rename from contracts/gauge/src/contract.rs rename to contracts/gauges/gauge/src/contract.rs diff --git a/contracts/gauge/src/error.rs b/contracts/gauges/gauge/src/error.rs similarity index 100% rename from contracts/gauge/src/error.rs rename to contracts/gauges/gauge/src/error.rs diff --git a/contracts/gauge/src/helpers.rs b/contracts/gauges/gauge/src/helpers.rs similarity index 100% rename from contracts/gauge/src/helpers.rs rename to contracts/gauges/gauge/src/helpers.rs diff --git a/contracts/gauge/src/lib.rs b/contracts/gauges/gauge/src/lib.rs similarity index 100% rename from contracts/gauge/src/lib.rs rename to contracts/gauges/gauge/src/lib.rs diff --git a/contracts/gauge/src/msg.rs b/contracts/gauges/gauge/src/msg.rs similarity index 100% rename from contracts/gauge/src/msg.rs rename to contracts/gauges/gauge/src/msg.rs diff --git a/contracts/gauge/src/multitest/adapter.rs b/contracts/gauges/gauge/src/multitest/adapter.rs similarity index 100% rename from contracts/gauge/src/multitest/adapter.rs rename to contracts/gauges/gauge/src/multitest/adapter.rs diff --git a/contracts/gauge/src/multitest/gauge.rs b/contracts/gauges/gauge/src/multitest/gauge.rs similarity index 100% rename from contracts/gauge/src/multitest/gauge.rs rename to contracts/gauges/gauge/src/multitest/gauge.rs diff --git a/contracts/gauge/src/multitest/mod.rs b/contracts/gauges/gauge/src/multitest/mod.rs similarity index 100% rename from contracts/gauge/src/multitest/mod.rs rename to contracts/gauges/gauge/src/multitest/mod.rs diff --git a/contracts/gauge/src/multitest/reset.rs b/contracts/gauges/gauge/src/multitest/reset.rs similarity index 100% rename from contracts/gauge/src/multitest/reset.rs rename to contracts/gauges/gauge/src/multitest/reset.rs diff --git a/contracts/gauge/src/multitest/suite.rs b/contracts/gauges/gauge/src/multitest/suite.rs similarity index 100% rename from contracts/gauge/src/multitest/suite.rs rename to contracts/gauges/gauge/src/multitest/suite.rs diff --git a/contracts/gauge/src/multitest/tally.rs b/contracts/gauges/gauge/src/multitest/tally.rs similarity index 100% rename from contracts/gauge/src/multitest/tally.rs rename to contracts/gauges/gauge/src/multitest/tally.rs diff --git a/contracts/gauge/src/multitest/voting.rs b/contracts/gauges/gauge/src/multitest/voting.rs similarity index 100% rename from contracts/gauge/src/multitest/voting.rs rename to contracts/gauges/gauge/src/multitest/voting.rs diff --git a/contracts/gauge/src/state.rs b/contracts/gauges/gauge/src/state.rs similarity index 100% rename from contracts/gauge/src/state.rs rename to contracts/gauges/gauge/src/state.rs diff --git a/contracts/marketing-gauge-adapter/.cargo/config b/contracts/gauges/marketing-gauge-adapter/.cargo/config similarity index 100% rename from contracts/marketing-gauge-adapter/.cargo/config rename to contracts/gauges/marketing-gauge-adapter/.cargo/config diff --git a/contracts/marketing-gauge-adapter/Cargo.toml b/contracts/gauges/marketing-gauge-adapter/Cargo.toml similarity index 100% rename from contracts/marketing-gauge-adapter/Cargo.toml rename to contracts/gauges/marketing-gauge-adapter/Cargo.toml diff --git a/contracts/marketing-gauge-adapter/README.md b/contracts/gauges/marketing-gauge-adapter/README.md similarity index 100% rename from contracts/marketing-gauge-adapter/README.md rename to contracts/gauges/marketing-gauge-adapter/README.md diff --git a/contracts/marketing-gauge-adapter/src/bin/schema.rs b/contracts/gauges/marketing-gauge-adapter/src/bin/schema.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/bin/schema.rs rename to contracts/gauges/marketing-gauge-adapter/src/bin/schema.rs diff --git a/contracts/marketing-gauge-adapter/src/contract.rs b/contracts/gauges/marketing-gauge-adapter/src/contract.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/contract.rs rename to contracts/gauges/marketing-gauge-adapter/src/contract.rs diff --git a/contracts/marketing-gauge-adapter/src/error.rs b/contracts/gauges/marketing-gauge-adapter/src/error.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/error.rs rename to contracts/gauges/marketing-gauge-adapter/src/error.rs diff --git a/contracts/marketing-gauge-adapter/src/lib.rs b/contracts/gauges/marketing-gauge-adapter/src/lib.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/lib.rs rename to contracts/gauges/marketing-gauge-adapter/src/lib.rs diff --git a/contracts/marketing-gauge-adapter/src/msg.rs b/contracts/gauges/marketing-gauge-adapter/src/msg.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/msg.rs rename to contracts/gauges/marketing-gauge-adapter/src/msg.rs diff --git a/contracts/marketing-gauge-adapter/src/multitest/mod.rs b/contracts/gauges/marketing-gauge-adapter/src/multitest/mod.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/multitest/mod.rs rename to contracts/gauges/marketing-gauge-adapter/src/multitest/mod.rs diff --git a/contracts/marketing-gauge-adapter/src/multitest/options.rs b/contracts/gauges/marketing-gauge-adapter/src/multitest/options.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/multitest/options.rs rename to contracts/gauges/marketing-gauge-adapter/src/multitest/options.rs diff --git a/contracts/marketing-gauge-adapter/src/multitest/submission.rs b/contracts/gauges/marketing-gauge-adapter/src/multitest/submission.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/multitest/submission.rs rename to contracts/gauges/marketing-gauge-adapter/src/multitest/submission.rs diff --git a/contracts/marketing-gauge-adapter/src/multitest/suite.rs b/contracts/gauges/marketing-gauge-adapter/src/multitest/suite.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/multitest/suite.rs rename to contracts/gauges/marketing-gauge-adapter/src/multitest/suite.rs diff --git a/contracts/marketing-gauge-adapter/src/state.rs b/contracts/gauges/marketing-gauge-adapter/src/state.rs similarity index 100% rename from contracts/marketing-gauge-adapter/src/state.rs rename to contracts/gauges/marketing-gauge-adapter/src/state.rs From 015576bfd431a3232e4ea382e3633868fe0fbc2d Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Thu, 4 Jul 2024 20:17:06 -0400 Subject: [PATCH 09/12] Clean up, appease clippy gods --- contracts/gauges/README.md | 7 ------- contracts/gauges/gauge-adapter/src/multitest/suite.rs | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/contracts/gauges/README.md b/contracts/gauges/README.md index a46a084e8..68c610ec7 100644 --- a/contracts/gauges/README.md +++ b/contracts/gauges/README.md @@ -1,10 +1,3 @@ # Gauges Forked from [Wynd DAO repo](https://github.com/wynddao/wynddao), modified to support any type of DAO. - -## TODO -- [ ] More tests (consider refactoring with cw-orch) -- [ ] Make improvements to `GaugeAdapter` API -- [ ] Improve `GaugeAdapter` examples -- [ ] Does not handle small numbers elegantly (a problem with NFT DAOs, where people might stake 1 NFT) -- [ ] Better READMEs diff --git a/contracts/gauges/gauge-adapter/src/multitest/suite.rs b/contracts/gauges/gauge-adapter/src/multitest/suite.rs index 254868ffb..53ac2b14d 100644 --- a/contracts/gauges/gauge-adapter/src/multitest/suite.rs +++ b/contracts/gauges/gauge-adapter/src/multitest/suite.rs @@ -67,7 +67,7 @@ impl SuiteBuilder { } pub fn with_community_pool(mut self, community_pool: &str) -> Self { - self.community_pool = community_pool.to_owned(); + self.community_pool = community_pool.to_string(); self } From 05ead134a04064d9f2156fbfd1d492bc1be2b8e0 Mon Sep 17 00:00:00 2001 From: ismellike Date: Sun, 21 Jul 2024 10:38:28 -0500 Subject: [PATCH 10/12] Use cw-denom and payment utils in gauge adapter (#847) * Use cw-denom and payment utils in gauge adapter TODO: 2 tests are failing, because the cw20 cannot be validated with cw-denom. This should be fixed with a cw-orch refactor. * gauge adapter cw-orch tests * include suite changes * cleanup adapter test * A couple tweaks to gauge adapter tests * cleanup tests a bit * cargo lock * bump prost, lint * lint --------- Co-authored-by: hard-nett Co-authored-by: Hard-Nett <123711748+hard-nett@users.noreply.github.com> --- Cargo.lock | 636 ++++++++++++++-- Cargo.toml | 7 +- contracts/gauges/gauge-adapter/Cargo.toml | 6 + .../gauges/gauge-adapter/src/contract.rs | 240 ++---- contracts/gauges/gauge-adapter/src/error.rs | 11 +- contracts/gauges/gauge-adapter/src/helpers.rs | 27 + contracts/gauges/gauge-adapter/src/lib.rs | 1 + contracts/gauges/gauge-adapter/src/msg.rs | 18 +- .../gauge-adapter/src/multitest/options.rs | 100 ++- .../gauge-adapter/src/multitest/submission.rs | 698 ++++++++++-------- .../gauge-adapter/src/multitest/suite.rs | 402 +++------- contracts/gauges/gauge-adapter/src/state.rs | 27 +- contracts/gauges/gauge/README.md | 6 +- 13 files changed, 1289 insertions(+), 890 deletions(-) create mode 100644 contracts/gauges/gauge-adapter/src/helpers.rs diff --git a/Cargo.lock b/Cargo.lock index 2e345b014..e5bf0491f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,273 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "abstract-cw-multi-test" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c77f8d4bac08f74fbc4fce8943cb2d35e742682b6cae8cb65555d6cd3830feb" +dependencies = [ + "anyhow", + "bech32 0.11.0", + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw20-ics20", + "derivative", + "hex", + "itertools 0.12.1", + "log", + "prost 0.12.6", + "schemars", + "serde", + "serde_json", + "sha2 0.10.8", + "thiserror", +] + +[[package]] +name = "abstract-cw-plus-interface" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7441425a805439500492977107d154af02b1702aa044945775c245d0b3469968" +dependencies = [ + "abstract-cw1", + "abstract-cw1-subkeys", + "abstract-cw1-whitelist", + "abstract-cw20-base", + "abstract-cw20-ics20", + "abstract-cw3-fixed-multisig", + "abstract-cw3-flex-multisig", + "abstract-cw4-group", + "abstract-cw4-stake", + "cosmwasm-std", + "cw-orch", +] + +[[package]] +name = "abstract-cw1" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0895c076ab6a5165133a453f983ec9ccc9b6c41de256b6eb74e523eb555b3ebb" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "schemars", + "serde", +] + +[[package]] +name = "abstract-cw1-subkeys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab08cdd6008afa38a52427943bf4aef9541bde78cc9c14849a53ad2608a1161e" +dependencies = [ + "abstract-cw1", + "abstract-cw1-whitelist", + "abstract-cw2", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw1-whitelist" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171a0b5b3694627cf0fa554500d72431169d4013fffd14650d2b7d660230a205" +dependencies = [ + "abstract-cw1", + "abstract-cw2", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw2" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945af4c176b4539be2a74c06aa166287ba964ab58aec98c644addd812431f141" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw20" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d5e4b8084c3a2b3e42502e6c4fe3ed985dc72e86eb612bcc527f4a0443fa42" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-utils 1.0.3", + "schemars", + "serde", +] + +[[package]] +name = "abstract-cw20-base" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d300dec7d602e00841c5ab6fe598d4d290bab32e489c6885c607633c4f3fe67" +dependencies = [ + "abstract-cw2", + "abstract-cw20", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw20-ics20" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027678ddb0e62b4aba5f0167d2b0a3ec0182e1e32c47759be7e30b56775598ee" +dependencies = [ + "abstract-cw2", + "abstract-cw20", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "semver", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw3" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c080cc760333d1d3477857aeac19aa7e6e661f1e58d04a7a78212913d49bf517" +dependencies = [ + "abstract-cw20", + "cosmwasm-schema", + "cosmwasm-std", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw3-fixed-multisig" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1882e05bef33bd1c6b25e735eda8a23332a78c4df0b24a18ca56a8ca8ed6f222" +dependencies = [ + "abstract-cw2", + "abstract-cw3", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw3-flex-multisig" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92379f3e7c467f081312d6953eb8d300456efa352c9f7c5ef095ad99083d92db" +dependencies = [ + "abstract-cw2", + "abstract-cw20", + "abstract-cw3", + "abstract-cw3-fixed-multisig", + "abstract-cw4", + "cosmwasm-schema", + "cosmwasm-std", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw4" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aacb0124dce37ee6f2b5636684285bcbaa65a1678980f95ea76366ab74a8912" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "schemars", + "serde", +] + +[[package]] +name = "abstract-cw4-group" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0af5ef484ba1d48fee8485452c81ac3465ba16a5941db90bda4dd6b58b50a9a6" +dependencies = [ + "abstract-cw2", + "abstract-cw4", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "abstract-cw4-stake" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1eb9985e8b752396a2c5d8fde8ebf65ea81070a95f167a3d31af0746f8e4b4e" +dependencies = [ + "abstract-cw2", + "abstract-cw20", + "abstract-cw4", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "addr2line" version = "0.22.0" @@ -68,18 +335,18 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] name = "async-trait" -version = "0.1.80" +version = "0.1.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -184,6 +451,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" + [[package]] name = "bindgen" version = "0.68.1" @@ -203,7 +476,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.68", + "syn 2.0.71", "which", ] @@ -307,18 +580,18 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" dependencies = [ "serde", ] [[package]] name = "cc" -version = "1.0.104" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74b6a57f98764a267ff415d50a25e6e166f3831a5071af4995296ea97d210490" +checksum = "2aba8f4e9906c7ce3c73463f62a7f0c65183ada1a2d47e397cc8810827f9694f" [[package]] name = "cexpr" @@ -380,6 +653,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -463,6 +745,17 @@ dependencies = [ "tonic 0.9.2", ] +[[package]] +name = "cosmos-sdk-proto" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e23f6ab56d5f031cde05b8b82a5fefd3a1a223595c79e32317a97189e612bc" +dependencies = [ + "prost 0.12.6", + "prost-types 0.12.6", + "tendermint-proto 0.35.0", +] + [[package]] name = "cosmrs" version = "0.9.0" @@ -559,7 +852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c1556156fdf892a55cced6115968b961eaaadd6f724a2c2cb7d1e168e32dd3" dependencies = [ "base64 0.21.7", - "bech32", + "bech32 0.9.1", "bnum", "cosmwasm-crypto", "cosmwasm-derive", @@ -845,6 +1138,96 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw-orch" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1ddc937c28c59ccf2765fa05ddc0437644d3b283408a7cc64f7b371b0b9309" +dependencies = [ + "anyhow", + "cosmwasm-std", + "cw-orch-contract-derive", + "cw-orch-core", + "cw-orch-fns-derive", + "cw-orch-mock", + "cw-orch-traits", + "cw-utils 1.0.3", + "hex", + "log", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-orch-contract-derive" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc8ba75692fc7bd30e91c78fad2dc208a738e4e6ea26b232f9352c320e35543" +dependencies = [ + "convert_case", + "quote", + "syn 2.0.71", +] + +[[package]] +name = "cw-orch-core" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81af5ba67fdc434c6e90739ae89b91bff4dc2b87dc85f8a41aa822329c951bf8" +dependencies = [ + "abstract-cw-multi-test", + "anyhow", + "cosmos-sdk-proto 0.21.1", + "cosmwasm-std", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "dirs", + "log", + "serde", + "serde_json", + "sha2 0.10.8", + "thiserror", +] + +[[package]] +name = "cw-orch-fns-derive" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9acb7a15bfacc52abdf312a9fffb139883c1effb6ea7e645cd39580a8527463" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "cw-orch-mock" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae9536620b86ee78c2729fd8449538feb4f6257a9809c72c5f9e461e720cf3b" +dependencies = [ + "abstract-cw-multi-test", + "cosmwasm-std", + "cw-orch-core", + "cw-utils 1.0.3", + "log", + "serde", + "sha2 0.10.8", +] + +[[package]] +name = "cw-orch-traits" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5959ce29e9d8a52594b47933a0a2736ea94dd9bf5e29b220cbdbe2b097f07c3a" +dependencies = [ + "cw-orch-core", + "prost 0.12.6", + "prost-types 0.12.6", +] + [[package]] name = "cw-ownable" version = "0.5.1" @@ -1019,7 +1402,7 @@ dependencies = [ "dao-interface", "osmosis-std", "osmosis-test-tube", - "prost 0.12.6", + "prost 0.13.1", "schemars", "serde", "serde_json", @@ -1035,8 +1418,8 @@ dependencies = [ "dao-interface", "osmosis-std", "osmosis-std-derive", - "prost 0.12.6", - "prost-types 0.12.6", + "prost 0.13.1", + "prost-types 0.13.1", "schemars", "serde", "serde-cw-value", @@ -1267,6 +1650,25 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw20-ics20" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76221201da08fed611c857ea3aa21c031a4a7dc771a8b1750559ca987335dc02" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw20 1.1.2", + "schemars", + "semver", + "serde", + "thiserror", +] + [[package]] name = "cw20-stake" version = "0.2.6" @@ -2237,6 +2639,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dlv-list" version = "0.3.0" @@ -2511,7 +2934,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -2548,10 +2971,16 @@ dependencies = [ name = "gauge-adapter" version = "2.4.2" dependencies = [ + "abstract-cw-plus-interface", + "abstract-cw20", + "abstract-cw20-base", "anyhow", "cosmwasm-schema", "cosmwasm-std", + "cw-denom", "cw-multi-test", + "cw-orch", + "cw-orch-core", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw2 1.1.2", @@ -2773,9 +3202,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.29" +version = "0.14.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f361cde2f109281a220d4307746cdfd5ee3f410da58a70377762396775634b33" +checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" dependencies = [ "bytes", "futures-channel", @@ -2965,6 +3394,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -3055,6 +3493,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -3138,6 +3586,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.71", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3193,6 +3652,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -3341,7 +3806,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -3372,7 +3837,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -3420,7 +3885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -3464,6 +3929,16 @@ dependencies = [ "prost-derive 0.12.6", ] +[[package]] +name = "prost" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13db3d3fde688c61e2446b4d843bc27a7e8af269a69440c0308021dc92333cc" +dependencies = [ + "bytes", + "prost-derive 0.13.1", +] + [[package]] name = "prost-derive" version = "0.11.9" @@ -3487,7 +3962,20 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", +] + +[[package]] +name = "prost-derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18bec9b0adc4eba778b33684b7ba3e7137789434769ee3ce3930463ef904cfca" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "proc-macro2", + "quote", + "syn 2.0.71", ] [[package]] @@ -3508,6 +3996,15 @@ dependencies = [ "prost 0.12.6", ] +[[package]] +name = "prost-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cee5168b05f49d4b0ca581206eb14a7b22fafd963efe729ac48eb03266e25cc2" +dependencies = [ + "prost 0.13.1", +] + [[package]] name = "quote" version = "1.0.36" @@ -3553,6 +4050,17 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_users" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.10.5" @@ -3760,7 +4268,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -3803,9 +4311,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.6.0", "core-foundation", @@ -3816,9 +4324,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" dependencies = [ "core-foundation-sys", "libc", @@ -3832,9 +4340,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.203" +version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" dependencies = [ "serde_derive", ] @@ -3868,13 +4376,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.203" +version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -3885,7 +4393,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -3907,7 +4415,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -4131,9 +4639,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.68" +version = "2.0.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" +checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462" dependencies = [ "proc-macro2", "quote", @@ -4244,7 +4752,7 @@ checksum = "68ce80bf536476db81ecc9ebab834dc329c9c1509a694f211a73858814bfe023" dependencies = [ "bytes", "flex-error", - "num-derive", + "num-derive 0.3.3", "num-traits", "prost 0.11.9", "prost-types 0.11.9", @@ -4262,7 +4770,7 @@ checksum = "974d6330a19dfa6720e9f663fc59101d207a817db3f9c730d3f31caaa565b574" dependencies = [ "bytes", "flex-error", - "num-derive", + "num-derive 0.3.3", "num-traits", "prost 0.11.9", "prost-types 0.11.9", @@ -4280,7 +4788,7 @@ checksum = "c0cec054567d16d85e8c3f6a3139963d1a66d9d3051ed545d31562550e9bcc3d" dependencies = [ "bytes", "flex-error", - "num-derive", + "num-derive 0.3.3", "num-traits", "prost 0.11.9", "prost-types 0.11.9", @@ -4290,6 +4798,24 @@ dependencies = [ "time", ] +[[package]] +name = "tendermint-proto" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff525d5540a9fc535c38dc0d92a98da3ee36fcdfbda99cecb9f3cce5cd4d41d7" +dependencies = [ + "bytes", + "flex-error", + "num-derive 0.4.2", + "num-traits", + "prost 0.12.6", + "prost-types 0.12.6", + "serde", + "serde_bytes", + "subtle-encoding", + "time", +] + [[package]] name = "tendermint-rpc" version = "0.23.9" @@ -4384,7 +4910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d506c7664333e246f564949bee4ed39062aa0f11918e6f5a95f553cdad65c274" dependencies = [ "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -4405,22 +4931,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.61" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.61" +version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -4442,9 +4968,9 @@ checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" [[package]] name = "tinyvec" -version = "1.6.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55115c6fbe2d2bef26eb09ad74bde02d8255476fc0c7b515ef09fbb35742d82" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" dependencies = [ "tinyvec_macros", ] @@ -4457,9 +4983,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.38.0" +version = "1.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df" dependencies = [ "backtrace", "bytes", @@ -4490,7 +5016,7 @@ checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -4648,7 +5174,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] [[package]] @@ -4709,6 +5235,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4802,7 +5334,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", "wasm-bindgen-shared", ] @@ -4824,7 +5356,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5084,5 +5616,5 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.68", + "syn 2.0.71", ] diff --git a/Cargo.toml b/Cargo.toml index 4b3718b32..98fd3c45f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,8 +60,8 @@ osmosis-std = "0.20.1" osmosis-std-derive = "0.20.1" osmosis-test-tube = "20.1.1" proc-macro2 = "1.0" -prost = { version = "0.12.3", features = ["prost-derive"] } -prost-types = { version = "0.12.3", default-features = false } +prost = { version = "0.13.1", features = ["prost-derive"] } +prost-types = { version = "0.13.1", default-features = false } quote = "1.0" rand = "0.8" schemars = "0.8" @@ -78,6 +78,9 @@ syn = { version = "1.0", features = ["derive"] } test-context = "0.1" thiserror = { version = "1.0" } wynd-utils = "0.4" +cw-orch = "0.22.2" +cw-orch-core = "1.0.0-rc" + # One commit ahead of version 0.3.0. Allows initialization with an # optional owner. diff --git a/contracts/gauges/gauge-adapter/Cargo.toml b/contracts/gauges/gauge-adapter/Cargo.toml index a03773ebe..f8989d57d 100644 --- a/contracts/gauges/gauge-adapter/Cargo.toml +++ b/contracts/gauges/gauge-adapter/Cargo.toml @@ -24,12 +24,18 @@ cosmwasm-std = { workspace = true } cw-storage-plus = { workspace = true } cw2 = { workspace = true } cw20 = { workspace = true } +cw-denom = { workspace = true } +cw-orch = { workspace = true } cw-utils = { workspace = true } semver = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +abstract-cw-plus-interface = "2.0.1" +abstract-cw20 = "2.0.0" +abstract-cw20-base = "2.0.0" anyhow = { workspace = true } cw-multi-test = { workspace = true } cw20 = { workspace = true } cw20-base = { workspace = true } +cw-orch-core = { workspace = true } diff --git a/contracts/gauges/gauge-adapter/src/contract.rs b/contracts/gauges/gauge-adapter/src/contract.rs index a53cab416..fdcbec92d 100644 --- a/contracts/gauges/gauge-adapter/src/contract.rs +++ b/contracts/gauges/gauge-adapter/src/contract.rs @@ -1,15 +1,19 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - coins, from_json, to_json_binary, Addr, BankMsg, Binary, CosmosMsg, Deps, DepsMut, Env, - MessageInfo, Order, Response, StdError, StdResult, Uint128, WasmMsg, + from_json, to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, + StdResult, Uint128, }; use cw2::set_contract_version; -use cw20::{Cw20ExecuteMsg, Cw20ReceiveMsg}; - -use crate::error::ContractError; -use crate::msg::{AdapterQueryMsg, ExecuteMsg, InstantiateMsg, MigrateMsg, ReceiveMsg}; -use crate::state::{AssetType, Config, Submission, CONFIG, SUBMISSIONS}; +use cw20::Cw20ReceiveMsg; +use cw_denom::UncheckedDenom; +use cw_utils::{one_coin, PaymentError}; + +use crate::{ + error::ContractError, + msg::{AdapterQueryMsg, AssetUnchecked, ExecuteMsg, InstantiateMsg, MigrateMsg, ReceiveMsg}, + state::{Config, Submission, CONFIG, SUBMISSIONS}, +}; // Version info for migration info. const CONTRACT_NAME: &str = "crates.io:marketing-gauge-adapter"; @@ -37,9 +41,12 @@ pub fn instantiate( let config = Config { admin: deps.api.addr_validate(&msg.admin)?, - required_deposit: msg.required_deposit, + required_deposit: msg + .required_deposit + .map(|x| x.into_checked(deps.as_ref())) + .transpose()?, community_pool, - reward: msg.reward, + reward: msg.reward.into_checked(deps.as_ref())?, }; CONFIG.save(deps.storage, &config)?; @@ -56,17 +63,17 @@ pub fn execute( match msg { ExecuteMsg::Receive(msg) => receive_cw20_message(deps, info, msg), ExecuteMsg::CreateSubmission { name, url, address } => { - // TODO this is very hacky - let received = info.funds.into_iter().next().unwrap_or_default(); - execute::create_submission( - deps, - info.sender, - name, - url, - address, - received.amount, - AssetType::Native(received.denom), - ) + let received = match one_coin(&info) { + Ok(coin) => Ok(Some(coin)), + Err(PaymentError::NoFunds {}) => Ok(None), + Err(error) => Err(error), + }? + .map(|x| AssetUnchecked { + denom: UncheckedDenom::Native(x.denom), + amount: x.amount, + }); + + execute::create_submission(deps, info.sender, name, url, address, received) } ExecuteMsg::ReturnDeposits {} => execute::return_deposits(deps, info.sender), } @@ -78,44 +85,17 @@ fn receive_cw20_message( msg: Cw20ReceiveMsg, ) -> Result { match from_json(&msg.msg)? { - ReceiveMsg::CreateSubmission { name, url, address } => { - let denom = AssetType::Cw20(info.sender.to_string()); - execute::create_submission( - deps, - Addr::unchecked(msg.sender), - name, - url, - address, - msg.amount, - denom, - ) - } - } -} - -fn create_bank_msg( - denom: &AssetType, - amount: Uint128, - recipient: Addr, -) -> Result { - match denom { - AssetType::Cw20(address) => Ok(WasmMsg::Execute { - contract_addr: address.to_string(), - msg: to_json_binary(&Cw20ExecuteMsg::Transfer { - recipient: recipient.to_string(), - amount, - })?, - funds: vec![], - } - .into()), - AssetType::Native(denom) => { - let amount = coins(amount.u128(), denom); - Ok(BankMsg::Send { - to_address: recipient.to_string(), - amount, - } - .into()) - } + ReceiveMsg::CreateSubmission { name, url, address } => execute::create_submission( + deps, + Addr::unchecked(msg.sender), + name, + url, + address, + Some(AssetUnchecked::new_cw20( + info.sender.as_str(), + msg.amount.u128(), + )), + ), } } @@ -130,8 +110,7 @@ pub mod execute { name: String, url: String, address: String, - received_amount: Uint128, - received_denom: AssetType, + received: Option, ) -> Result { let address = deps.api.addr_validate(&address)?; @@ -142,20 +121,23 @@ pub mod execute { admin: _, } = CONFIG.load(deps.storage)?; if let Some(required_deposit) = required_deposit { - if AssetType::Native("".into()) == received_denom { - return Err(ContractError::DepositRequired {}); - } - if required_deposit.denom != received_denom { - return Err(ContractError::InvalidDepositType {}); - } - if received_amount != required_deposit.amount { - return Err(ContractError::InvalidDepositAmount { - correct_amount: required_deposit.amount, - }); + if let Some(received) = received { + let received_denom = received.denom.into_checked(deps.as_ref())?; + + if required_deposit.denom != received_denom { + return Err(ContractError::InvalidDepositType {}); + } + if received.amount != required_deposit.amount { + return Err(ContractError::InvalidDepositAmount { + correct_amount: required_deposit.amount, + }); + } + } else { + return Err(ContractError::PaymentError(PaymentError::NoFunds {})); } - } else { + } else if let Some(received) = received { // If no deposit is required, then any deposit invalidates a submission. - if !received_amount.is_zero() { + if !received.amount.is_zero() { return Err(ContractError::InvalidDepositAmount { correct_amount: Uint128::zero(), }); @@ -190,11 +172,10 @@ pub mod execute { .range(deps.storage, None, None, Order::Ascending) .map(|item| { let (_submission_recipient, submission) = item?; - create_bank_msg( - &required_deposit.denom, - required_deposit.amount, - submission.sender, - ) + + required_deposit + .denom + .get_transfer_to_message(&submission.sender, required_deposit.amount) }) .collect::>>()?; @@ -221,7 +202,7 @@ pub fn query(deps: Deps, _env: Env, msg: AdapterQueryMsg) -> StdResult { } mod query { - use cosmwasm_std::{CosmosMsg, Decimal}; + use cosmwasm_std::{CosmosMsg, Decimal, StdError}; use crate::msg::{ AllOptionsResponse, AllSubmissionsResponse, CheckOptionResponse, SampleGaugeMsgsResponse, @@ -254,12 +235,16 @@ mod query { let execute = winners .into_iter() .map(|(to_address, fraction)| { - // Gauge already sents chosen tally to this query by using results we send in + // Gauge already sends chosen tally to this query by using results we send in // all_options query; they are already validated - create_bank_msg( - &reward.denom, - fraction * reward.amount, - Addr::unchecked(to_address), + let to_address = deps.api.addr_validate(&to_address)?; + + reward.denom.get_transfer_to_message( + &to_address, + reward + .amount + .checked_mul_floor(fraction) + .map_err(|x| StdError::generic_err(x.to_string()))?, ) }) .collect::>>()?; @@ -306,20 +291,22 @@ mod tests { use super::*; use cosmwasm_std::{ + coins, testing::{mock_dependencies, mock_env, mock_info}, - Decimal, Uint128, + BankMsg, CosmosMsg, Decimal, Uint128, }; + use cw_denom::CheckedDenom; - use crate::state::Asset; + use crate::{msg::AssetUnchecked, state::Asset}; #[test] fn proper_initialization() { let mut deps = mock_dependencies(); let msg = InstantiateMsg { admin: "admin".to_owned(), - required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), + required_deposit: Some(AssetUnchecked::new_native("wynd", 10_000_000)), community_pool: "community".to_owned(), - reward: Asset::new_native("ujuno", 150_000_000_000), + reward: AssetUnchecked::new_native("ujuno", 150_000_000_000), }; instantiate( deps.as_mut(), @@ -335,7 +322,7 @@ mod tests { assert_eq!( config.required_deposit, Some(Asset { - denom: AssetType::Cw20("wynd".to_owned()), + denom: CheckedDenom::Native(String::from("wynd")), amount: Uint128::new(10_000_000) }) ); @@ -343,13 +330,13 @@ mod tests { assert_eq!( config.reward, Asset { - denom: AssetType::Native("ujuno".to_owned()), + denom: CheckedDenom::Native("ujuno".to_owned()), amount: Uint128::new(150_000_000_000) } ); let msg = InstantiateMsg { - reward: Asset::new_native("ujuno", 10_000_000), + reward: AssetUnchecked::new_native("ujuno", 10_000_000), ..msg }; instantiate( @@ -363,7 +350,7 @@ mod tests { assert_eq!( config.reward, Asset { - denom: AssetType::Native("ujuno".to_owned()), + denom: CheckedDenom::Native("ujuno".to_owned()), amount: Uint128::new(10_000_000) } ); @@ -384,9 +371,9 @@ mod tests { let reward = Uint128::new(150_000_000_000); let msg = InstantiateMsg { admin: "admin".to_owned(), - required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), + required_deposit: Some(AssetUnchecked::new_native("wynd", 10_000_000)), community_pool: "community".to_owned(), - reward: Asset::new_native("ujuno", reward.into()), + reward: AssetUnchecked::new_native("ujuno", reward.into()), }; instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); @@ -425,69 +412,6 @@ mod tests { ); } - #[test] - fn sample_gauge_msgs_cw20() { - let mut deps = mock_dependencies(); - - let reward = Uint128::new(150_000_000_000); - let msg = InstantiateMsg { - admin: "admin".to_owned(), - required_deposit: Some(Asset::new_cw20("wynd", 10_000_000)), - community_pool: "community".to_owned(), - reward: Asset::new_cw20("wynd", reward.into()), - }; - instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); - - let selected = vec![ - ( - "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), - Decimal::percent(41), - ), - ( - "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), - Decimal::percent(33), - ), - ( - "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), - Decimal::percent(26), - ), - ]; - let res = query::sample_gauge_msgs(deps.as_ref(), selected).unwrap(); - assert_eq!(res.execute.len(), 3); - assert_eq!( - res.execute, - [ - CosmosMsg::Wasm(WasmMsg::Execute { - contract_addr: "wynd".to_owned(), - msg: to_json_binary(&Cw20ExecuteMsg::Transfer { - recipient: "juno1t8ehvswxjfn3ejzkjtntcyrqwvmvuknzy3ajxy".to_string(), - amount: reward * Decimal::percent(41) - }) - .unwrap(), - funds: vec![] - }), - CosmosMsg::Wasm(WasmMsg::Execute { - contract_addr: "wynd".to_owned(), - msg: to_json_binary(&Cw20ExecuteMsg::Transfer { - recipient: "juno196ax4vc0lwpxndu9dyhvca7jhxp70rmcl99tyh".to_string(), - amount: reward * Decimal::percent(33) - }) - .unwrap(), - funds: vec![] - }), - CosmosMsg::Wasm(WasmMsg::Execute { - contract_addr: "wynd".to_owned(), - msg: to_json_binary(&Cw20ExecuteMsg::Transfer { - recipient: "juno1y0us8xvsvfvqkk9c6nt5cfyu5au5tww23dmh40".to_string(), - amount: reward * Decimal::percent(26) - }) - .unwrap(), - funds: vec![] - }), - ] - ); - } - #[test] fn return_deposits_authorization() { let mut deps = mock_dependencies(); @@ -495,7 +419,7 @@ mod tests { admin: "admin".to_owned(), required_deposit: None, community_pool: "community".to_owned(), - reward: Asset::new_native("ujuno", 150_000_000_000), + reward: AssetUnchecked::new_native("ujuno", 150_000_000_000), }; instantiate( deps.as_mut(), @@ -509,7 +433,7 @@ mod tests { assert_eq!(err, ContractError::NoDepositToRefund {}); let msg = InstantiateMsg { - required_deposit: Some(Asset::new_native("ujuno", 10_000_000)), + required_deposit: Some(AssetUnchecked::new_native("ujuno", 10_000_000)), ..msg }; instantiate(deps.as_mut(), mock_env(), mock_info("user", &[]), msg).unwrap(); diff --git a/contracts/gauges/gauge-adapter/src/error.rs b/contracts/gauges/gauge-adapter/src/error.rs index 75521219d..6994dc494 100644 --- a/contracts/gauges/gauge-adapter/src/error.rs +++ b/contracts/gauges/gauge-adapter/src/error.rs @@ -1,4 +1,6 @@ use cosmwasm_std::{StdError, Uint128}; +use cw_denom::DenomError; +use cw_utils::PaymentError; use thiserror::Error; #[derive(Error, Debug, PartialEq)] @@ -6,6 +8,12 @@ pub enum ContractError { #[error("{0}")] Std(#[from] StdError), + #[error("{0}")] + PaymentError(#[from] PaymentError), + + #[error("{0}")] + DenomError(#[from] DenomError), + #[error("Operation unauthorized - only admin can release deposits")] Unauthorized {}, @@ -20,7 +28,4 @@ pub enum ContractError { #[error("No deposit was required, therefore no deposit can be returned")] NoDepositToRefund {}, - - #[error("Deposit required, cannot create submission.")] - DepositRequired {}, } diff --git a/contracts/gauges/gauge-adapter/src/helpers.rs b/contracts/gauges/gauge-adapter/src/helpers.rs new file mode 100644 index 000000000..862be36d4 --- /dev/null +++ b/contracts/gauges/gauge-adapter/src/helpers.rs @@ -0,0 +1,27 @@ +use cosmwasm_std::Deps; +use cw_denom::{DenomError, UncheckedDenom}; + +use crate::{msg::AssetUnchecked, state::Asset}; + +impl AssetUnchecked { + pub fn into_checked(self, deps: Deps) -> Result { + Ok(Asset { + denom: self.denom.into_checked(deps)?, + amount: self.amount, + }) + } + + pub fn new_native(denom: &str, amount: u128) -> Self { + Self { + denom: UncheckedDenom::Native(denom.to_owned()), + amount: amount.into(), + } + } + + pub fn new_cw20(denom: &str, amount: u128) -> Self { + Self { + denom: UncheckedDenom::Cw20(denom.to_owned()), + amount: amount.into(), + } + } +} diff --git a/contracts/gauges/gauge-adapter/src/lib.rs b/contracts/gauges/gauge-adapter/src/lib.rs index 31d9a5aa4..aae805d76 100644 --- a/contracts/gauges/gauge-adapter/src/lib.rs +++ b/contracts/gauges/gauge-adapter/src/lib.rs @@ -1,5 +1,6 @@ pub mod contract; mod error; +mod helpers; pub mod msg; pub mod state; diff --git a/contracts/gauges/gauge-adapter/src/msg.rs b/contracts/gauges/gauge-adapter/src/msg.rs index dc1ae2d7c..5d8a16f7d 100644 --- a/contracts/gauges/gauge-adapter/src/msg.rs +++ b/contracts/gauges/gauge-adapter/src/msg.rs @@ -1,22 +1,22 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::{Addr, CosmosMsg, Decimal}; +use cosmwasm_std::{Addr, CosmosMsg, Decimal, Uint128}; use cw20::Cw20ReceiveMsg; - -use crate::state::Asset; +use cw_denom::UncheckedDenom; #[cw_serde] pub struct InstantiateMsg { /// Address that is allowed to return deposits. pub admin: String, /// Deposit required for valid submission. This option allows to reduce spam. - pub required_deposit: Option, + pub required_deposit: Option, /// Address of contract where each deposit is transferred. pub community_pool: String, /// Total reward amount. - pub reward: Asset, + pub reward: AssetUnchecked, } #[cw_serde] +#[derive(cw_orch::ExecuteFns)] pub enum ExecuteMsg { /// Implements the Cw20 receiver interface. Receive(Cw20ReceiveMsg), @@ -48,7 +48,7 @@ pub enum MigrateMsg {} // Queries copied from gauge-orchestrator for now (we could use a common crate for this). /// Queries the gauge requires from the adapter contract in order to function. #[cw_serde] -#[derive(QueryResponses)] +#[derive(QueryResponses, cw_orch::QueryFns)] pub enum AdapterQueryMsg { #[returns(crate::state::Config)] Config {}, @@ -97,3 +97,9 @@ pub struct SubmissionResponse { pub struct AllSubmissionsResponse { pub submissions: Vec, } + +#[cw_serde] +pub struct AssetUnchecked { + pub denom: UncheckedDenom, + pub amount: Uint128, +} diff --git a/contracts/gauges/gauge-adapter/src/multitest/options.rs b/contracts/gauges/gauge-adapter/src/multitest/options.rs index d1c57e6cf..2d6343cff 100644 --- a/contracts/gauges/gauge-adapter/src/multitest/options.rs +++ b/contracts/gauges/gauge-adapter/src/multitest/options.rs @@ -1,50 +1,80 @@ -use cosmwasm_std::{coin, Addr}; +use cosmwasm_std::{coin, coins}; +use cw_denom::UncheckedDenom; +use cw_orch::{contract::interface_traits::CwOrchQuery, mock::MockBech32}; -use crate::multitest::suite::SuiteBuilder; +use crate::{ + msg::{ + AdapterQueryMsg, AllOptionsResponse, AllSubmissionsResponse, AssetUnchecked, + CheckOptionResponse, + }, + multitest::suite::{native_submission_helper, setup_gauge_adapter}, +}; #[test] fn option_queries() { - let mut suite = SuiteBuilder::new() - .with_community_pool("community_pool") - .with_funds("owner", &[coin(100_000, "juno")]) - .with_funds("einstein", &[coin(100_000, "juno")]) - .with_native_deposit(1_000) - .build(); + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); - let recipient = "user".to_owned(); + let recipient = mock.addr_make("recipient"); + let newton = mock.addr_make("newton"); + let einstein = mock + .addr_make_with_balance("einstein", coins(1_000, "juno")) + .unwrap(); - let options = suite.query_all_options().unwrap(); + mock.add_balance(&mock.sender, coins(1_000, "juno")) + .unwrap(); + let options: AllSubmissionsResponse = + adapter.query(&AdapterQueryMsg::AllSubmissions {}).unwrap(); // account for a default option - assert_eq!(options.len(), 1); + assert_eq!(options.submissions.len(), 1); // Valid submission. - _ = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[coin(1_000, "juno")], - ) - .unwrap(); + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); // Valid submission. - suite - .execute_create_submission( - Addr::unchecked("einstein"), - "MIBers".to_owned(), - "https://www.mib.tech/".to_owned(), - "einstein".to_owned(), - &[coin(1_000, "juno")], - ) - .unwrap(); + native_submission_helper( + adapter.clone(), + einstein.clone(), + einstein.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); - let options = suite.query_all_options().unwrap(); - assert_eq!(options, vec!["community_pool", "einstein", &recipient],); + let options: AllOptionsResponse = adapter.query(&AdapterQueryMsg::AllOptions {}).unwrap(); + assert_eq!( + options, + AllOptionsResponse { + options: vec![ + einstein.to_string(), + mock.addr_make("community_pool").to_string(), + recipient.to_string() + ] + }, + ); - let option = suite.query_check_option("einstein".to_owned()).unwrap(); - assert!(option); + let option: CheckOptionResponse = adapter + .query(&AdapterQueryMsg::CheckOption { + option: einstein.to_string(), + }) + .unwrap(); + assert!(option.valid); - let option = suite.query_check_option("newton".to_owned()).unwrap(); - assert!(!option); + let option: CheckOptionResponse = adapter + .query(&AdapterQueryMsg::CheckOption { + option: newton.to_string(), + }) + .unwrap(); + assert!(!option.valid); } diff --git a/contracts/gauges/gauge-adapter/src/multitest/submission.rs b/contracts/gauges/gauge-adapter/src/multitest/submission.rs index 4243ace50..41f8a88cb 100644 --- a/contracts/gauges/gauge-adapter/src/multitest/submission.rs +++ b/contracts/gauges/gauge-adapter/src/multitest/submission.rs @@ -1,45 +1,60 @@ -use crate::{msg::SubmissionResponse, ContractError}; - -use super::suite::SuiteBuilder; - -use cosmwasm_std::{coin, Addr, Uint128}; +use crate::{ + msg::{ + AdapterQueryMsg, AdapterQueryMsgFns, AllSubmissionsResponse, AssetUnchecked, ExecuteMsg, + ExecuteMsgFns, ReceiveMsg, SubmissionResponse, + }, + multitest::suite::{ + cw20_helper, native_submission_helper, setup_cw20_reward_gauge_adapter, setup_gauge_adapter, + }, + ContractError, +}; + +use abstract_cw20::{msg::Cw20ExecuteMsgFns, Cw20ExecuteMsg as AbsCw20ExecuteMsg}; +use abstract_cw20_base::msg::QueryMsgFns; +use cosmwasm_std::{coin, to_json_binary, Addr, CosmosMsg, Decimal, Uint128, WasmMsg}; +use cw_denom::UncheckedDenom; +use cw_orch::{contract::interface_traits::CwOrchExecute, mock::MockBech32, prelude::*}; #[test] fn create_default_submission() { - let suite = SuiteBuilder::new() - .with_community_pool("community_pool") - .build(); + let mock = MockBech32::new("mock"); + let treasury = &mock.addr_make("community_pool"); + + let adapter = setup_gauge_adapter(mock.clone(), None); // this one is created by default during instantiation assert_eq!( SubmissionResponse { - sender: suite.gauge_adapter.clone(), + sender: adapter.address().unwrap(), name: "Unimpressed".to_owned(), url: "Those funds go back to the community pool".to_owned(), - address: Addr::unchecked("community_pool"), + address: treasury.clone(), }, - suite.query_submission("community_pool".to_owned()).unwrap() + adapter + .query(&crate::msg::AdapterQueryMsg::Submission { + address: treasury.to_string() + }) + .unwrap() ) } #[test] fn create_submission_no_required_deposit() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(100_000, "juno")]) - .build(); + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter(mock.clone(), None); - let recipient = "user".to_owned(); + let recipient = mock.addr_make("recipient"); + mock.add_balance(&mock.sender, vec![coin(1_000, "juno")]) + .unwrap(); // Fails send funds along with the tx. - let err = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[coin(1_000, "juno")], - ) - .unwrap_err(); + let err = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(coin(1_000, "juno")), + ) + .unwrap_err(); assert_eq!( ContractError::InvalidDepositAmount { @@ -49,131 +64,116 @@ fn create_submission_no_required_deposit() { ); // Valid submission. - _ = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[], - ) - .unwrap(); + let result = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + None, + ); + assert!(result.is_ok()); assert_eq!( SubmissionResponse { - sender: suite.owner.clone(), - name: "WYNDers".to_owned(), - url: "https://www.wynddao.com/".to_owned(), - address: Addr::unchecked(recipient.clone()), + sender: mock.sender, + name: "DAOers".to_owned(), + url: "https://daodao.zone".to_owned(), + address: recipient.clone(), }, - suite.query_submission(recipient).unwrap() + adapter + .query(&crate::msg::AdapterQueryMsg::Submission { + address: recipient.to_string() + }) + .unwrap(), ) } #[test] fn overwrite_existing_submission() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(100_000, "juno")]) - .build(); - - let recipient = "user".to_owned(); - - suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[], - ) - .unwrap(); + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter(mock.clone(), None); + let recipient = mock.addr_make("recipient"); + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + None, + ) + .unwrap(); assert_eq!( SubmissionResponse { - sender: suite.owner.clone(), - name: "WYNDers".to_owned(), - url: "https://www.wynddao.com/".to_owned(), - address: Addr::unchecked(recipient.clone()), + sender: mock.sender.clone(), + name: "DAOers".to_owned(), + url: "https://daodao.zone".to_string(), + address: recipient.clone(), }, - suite.query_submission(recipient.clone()).unwrap() + adapter.submission(recipient.to_string()).unwrap() ); // Try to submit to the same address with different user - let err = suite - .execute_create_submission( - Addr::unchecked("anotheruser"), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[], - ) - .unwrap_err(); - assert_eq!( - ContractError::UnauthorizedSubmission {}, - err.downcast().unwrap() - ); + let err = native_submission_helper( + adapter.clone(), + Addr::unchecked("anotheruser"), + recipient.clone(), + None, + ) + .unwrap_err(); - // Overwriting submission as same author works - let err = suite - .execute_create_submission( - Addr::unchecked("anotheruser"), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[], - ) - .unwrap_err(); assert_eq!( ContractError::UnauthorizedSubmission {}, err.downcast().unwrap() ); - suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "wynddao".to_owned(), - recipient.clone(), - &[], - ) - .unwrap(); + // Overwriting submission as same author works + native_submission_helper(adapter.clone(), mock.sender, recipient.clone(), None).unwrap(); - let response = suite.query_submission(recipient).unwrap(); - assert_eq!(response.url, "wynddao".to_owned()); + let response = adapter.submission(recipient.to_string()).unwrap(); + assert_eq!(response.url, "https://daodao.zone".to_owned()); } #[test] fn create_submission_required_deposit() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(100_000, "juno"), coin(100_000, "wynd")]) - .with_native_deposit(1_000) - .build(); + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); - let recipient = "user".to_owned(); + let recipient = mock.addr_make("recipient"); + mock.add_balance(&mock.sender.clone(), vec![coin(1_000, "wynd")]) + .unwrap(); + mock.add_balance(&mock.sender.clone(), vec![coin(1_000, "juno")]) + .unwrap(); // Fails if no funds sent. - let err = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[], - ) - .unwrap_err(); + let err = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + None, + ) + .unwrap_err(); - assert_eq!(ContractError::DepositRequired {}, err.downcast().unwrap()); + assert_eq!( + ContractError::PaymentError(cw_utils::PaymentError::NoFunds {}), + err.downcast().unwrap() + ); - // Fails if correct denom but not enought amount. - let err = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[coin(1, "juno")], - ) - .unwrap_err(); + // Fails if correct denom but not enough amount. + // Fails if no funds sent. + let err = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(Coin { + denom: "juno".into(), + amount: 999u128.into(), + }), + ) + .unwrap_err(); assert_eq!( ContractError::InvalidDepositAmount { @@ -183,15 +183,16 @@ fn create_submission_required_deposit() { ); // Fails if enough amount but incorrect denom. - let err = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[coin(1_000, "wynd")], - ) - .unwrap_err(); + let err = native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(Coin { + denom: "wynd".into(), + amount: 1_000u128.into(), + }), + ) + .unwrap_err(); assert_eq!( ContractError::InvalidDepositType {}, @@ -199,48 +200,69 @@ fn create_submission_required_deposit() { ); // Valid submission. - _ = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[coin(1_000, "juno")], - ) - .unwrap(); + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(Coin { + denom: "juno".into(), + amount: 1_000u128.into(), + }), + ) + .unwrap(); assert_eq!( SubmissionResponse { - sender: suite.owner.clone(), - name: "WYNDers".to_owned(), - url: "https://www.wynddao.com/".to_owned(), - address: Addr::unchecked(recipient.clone()), + sender: mock.sender.clone(), + name: "DAOers".to_owned(), + url: "https://daodao.zone".to_owned(), + address: recipient.clone(), }, - suite.query_submission(recipient).unwrap() + adapter + .query(&AdapterQueryMsg::Submission { + address: recipient.to_string() + }) + .unwrap() ) } #[test] fn create_receive_required_deposit() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(100_000, "juno")]) - .with_cw20_funds("owner", 1_000) - .with_cw20_deposit(1_000) - .build(); - - let recipient = "user".to_owned(); + let mock = MockBech32::new("mock"); + let cw20 = cw20_helper(mock.clone()); + let bad_cw20 = cw20_helper(mock.clone()); + let cw20_addr = cw20.address().unwrap(); + let bad_cw20_addr = bad_cw20.address().unwrap(); + println!("good cw20: {:#?}", cw20_addr); + println!("bad cw20: {:#?}", bad_cw20_addr); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Cw20(cw20_addr.to_string()), + amount: 1_000u128.into(), + }), + ); - let cw20_addr = suite.instantiate_token(suite.owner.clone().as_ref(), "moonbites", 1_000_000); + let recipient = mock.sender_addr().to_string(); + let binary_msg = to_json_binary(&ReceiveMsg::CreateSubmission { + name: "DAOers".into(), + url: "https://daodao.zone".into(), + address: recipient.clone(), + }) + .unwrap(); // Fails by sending wrong cw20. - let err = suite - .execute_receive_through_cw20( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - 1_000, - cw20_addr, + let err = adapter + .call_as(&Addr::unchecked( + "mock1mzdhwvvh22wrt07w59wxyd58822qavwkx5lcej7aqfkpqqlhaqfsetqc4t", + )) + .execute( + &ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { + sender: recipient.to_string(), + amount: Uint128::from(1_000u128), + msg: binary_msg.clone(), + }), + None, ) .unwrap_err(); @@ -250,14 +272,15 @@ fn create_receive_required_deposit() { ); // Fails by sending less tokens than required. - let err = suite - .execute_receive_through_cw20( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - 1, - suite.default_cw20.clone(), + let err = adapter + .call_as(&cw20.address().unwrap()) + .execute( + &ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { + sender: recipient.to_string(), + amount: Uint128::from(999u128), + msg: binary_msg.clone(), + }), + None, ) .unwrap_err(); @@ -269,38 +292,49 @@ fn create_receive_required_deposit() { ); // Valid submission. - _ = suite - .execute_receive_through_cw20( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - 1_000, - suite.default_cw20.clone(), + adapter + .call_as(&cw20.address().unwrap()) + .execute( + &ExecuteMsg::Receive(cw20::Cw20ReceiveMsg { + sender: recipient.to_string(), + amount: Uint128::from(1_000u128), + msg: binary_msg, + }), + None, ) .unwrap(); assert_eq!( SubmissionResponse { - sender: suite.owner.clone(), - name: "WYNDers".to_owned(), - url: "https://www.wynddao.com/".to_owned(), + sender: mock.sender.clone(), + name: "DAOers".to_owned(), + url: "https://daodao.zone".to_owned(), address: Addr::unchecked(recipient.clone()), }, - suite.query_submission(recipient).unwrap() + adapter + .query(&AdapterQueryMsg::Submission { + address: recipient.to_string() + }) + .unwrap() ); - assert_eq!(2, suite.query_submissions().unwrap().len()) + assert_eq!( + 2, + adapter + .query::(&AdapterQueryMsg::AllSubmissions {}) + .unwrap() + .submissions + .len() + ) } #[test] fn return_deposits_no_required_deposit() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(100_000, "juno")]) - .build(); + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter(mock.clone(), None); - let err = suite - .execute_return_deposit(suite.owner.clone().as_ref()) + let err = adapter + .execute(&ExecuteMsg::ReturnDeposits {}, None) .unwrap_err(); assert_eq!(ContractError::NoDepositToRefund {}, err.downcast().unwrap()) @@ -308,177 +342,245 @@ fn return_deposits_no_required_deposit() { #[test] fn return_deposits_no_admin() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(100_000, "juno")]) - .with_native_deposit(1_000) - .build(); + let mock = MockBech32::new("mock"); + let bad_addr = mock.addr_make("einstien"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); - let err = suite.execute_return_deposit("einstein").unwrap_err(); + let err = adapter + .call_as(&bad_addr) + .execute(&ExecuteMsg::ReturnDeposits {}, None) + .unwrap_err(); assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()) } #[test] fn return_deposits_required_native_deposit() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(1_000, "juno")]) - .with_native_deposit(1_000) - .build(); - - let recipient = "user".to_owned(); + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); + mock.add_balance(&mock.sender, vec![coin(1_000u128, "juno")]) + .unwrap(); + let recipient = mock.addr_make("recipient"); // Valid submission. - _ = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[coin(1_000, "juno")], - ) - .unwrap(); + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); assert_eq!( - suite.query_native_balance(suite.owner.as_ref()).unwrap(), - 0u128, + mock.query_balance(&mock.sender.clone(), "juno").unwrap(), + Uint128::zero() ); - assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); assert_eq!( - suite - .query_native_balance(suite.gauge_adapter.as_ref()) + mock.query_balance(&recipient, "juno").unwrap(), + Uint128::zero() + ); + assert_eq!( + mock.query_balance(&adapter.address().unwrap(), "juno") .unwrap(), - 1_000u128, + Uint128::from(1000u128) ); - _ = suite - .execute_return_deposit(suite.owner.clone().as_ref()) + adapter + .execute(&ExecuteMsg::ReturnDeposits {}, None) .unwrap(); - assert_eq!( - suite.query_native_balance(suite.owner.as_ref()).unwrap(), - 1_000u128, + mock.query_balance(&mock.sender.clone(), "juno").unwrap(), + Uint128::from(1000u128) ); - assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); assert_eq!( - suite - .query_native_balance(suite.gauge_adapter.as_ref()) + mock.query_balance(&recipient, "juno").unwrap(), + Uint128::zero() + ); + assert_eq!( + mock.query_balance(&adapter.address().unwrap(), "juno") .unwrap(), - 0u128, + Uint128::zero() ); } #[test] fn return_deposits_required_native_deposit_multiple_deposits() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(1_000, "juno")]) - .with_funds("einstein", &[coin(1_000, "juno")]) - .with_native_deposit(1_000) - .build(); - - let recipient = "user".to_owned(); - - // Valid submission. - _ = suite - .execute_create_submission( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - &[coin(1_000, "juno")], - ) - .unwrap(); + let mock = MockBech32::new("mock"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Native("juno".into()), + amount: 1_000u128.into(), + }), + ); - // Valid submission. - _ = suite - .execute_create_submission( - Addr::unchecked("einstein"), - "MIBers".to_owned(), - "https://www.mib.tech/".to_owned(), - "einstein".to_owned(), - &[coin(1_000, "juno")], - ) + let recipient = mock.addr_make("recipient"); + let einstien = mock + .addr_make_with_balance("einstien", vec![coin(1_000u128, "juno")]) .unwrap(); - - _ = suite - .execute_return_deposit(suite.owner.clone().as_ref()) + mock.add_balance(&mock.sender, vec![coin(1_000u128, "juno")]) .unwrap(); + // Valid submission. + native_submission_helper( + adapter.clone(), + mock.sender.clone(), + recipient.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); + // Valid submission. + native_submission_helper( + adapter.clone(), + einstien.clone(), + einstien.clone(), + Some(coin(1_000u128, "juno")), + ) + .unwrap(); + adapter.return_deposits().unwrap(); + assert_eq!( + mock.query_balance(&mock.sender.clone(), "juno").unwrap(), + Uint128::from(1000u128) + ); assert_eq!( - suite.query_native_balance(suite.owner.as_ref()).unwrap(), - 1_000u128, + mock.query_balance(&einstien, "juno").unwrap(), + Uint128::from(1000u128) ); - assert_eq!(suite.query_native_balance("einstein").unwrap(), 1_000u128,); - assert_eq!(suite.query_native_balance(&recipient).unwrap(), 0u128,); assert_eq!( - suite - .query_native_balance(suite.gauge_adapter.as_ref()) + mock.query_balance(&recipient, "juno").unwrap(), + Uint128::zero() + ); + assert_eq!( + mock.query_balance(&adapter.address().unwrap(), "juno") .unwrap(), - 0u128, + Uint128::zero() ); } #[test] fn return_deposits_required_cw20_deposit() { - let mut suite = SuiteBuilder::new() - .with_funds("owner", &[coin(1_000, "juno")]) - .with_cw20_funds("owner", 1_000) - .with_cw20_deposit(1_000) - .build(); - - let recipient = "user".to_owned(); + let mock = MockBech32::new("mock"); + let cw20 = cw20_helper(mock.clone()); + let recipient = mock.addr_make("recipient"); + let adapter = setup_gauge_adapter( + mock.clone(), + Some(AssetUnchecked { + denom: UncheckedDenom::Cw20(cw20.addr_str().unwrap()), + amount: 1_000u128.into(), + }), + ); + let binary_msg = to_json_binary(&ReceiveMsg::CreateSubmission { + name: "DAOers".into(), + url: "https://daodao.zone".into(), + address: recipient.to_string(), + }) + .unwrap(); // Valid submission. - _ = suite - .execute_receive_through_cw20( - suite.owner.clone(), - "WYNDers".to_owned(), - "https://www.wynddao.com/".to_owned(), - recipient.clone(), - 1_000, - suite.default_cw20.clone(), - ) + cw20.send(1_000u128.into(), adapter.addr_str().unwrap(), binary_msg) .unwrap(); assert_eq!( - suite - .query_cw20_balance(suite.owner.as_ref(), &suite.default_cw20) - .unwrap(), - 0u128, + cw20.balance(mock.sender.to_string()).unwrap().balance, + Uint128::from(999_000u128) ); assert_eq!( - suite - .query_cw20_balance(&recipient, &suite.default_cw20) - .unwrap(), - 0u128, + cw20.balance(recipient.to_string()).unwrap().balance, + Uint128::zero() ); assert_eq!( - suite - .query_cw20_balance(suite.gauge_adapter.as_ref(), &suite.default_cw20) - .unwrap(), - 1_000u128, + cw20.balance(adapter.address().unwrap().to_string()) + .unwrap() + .balance, + Uint128::from(1_000u128), ); - _ = suite - .execute_return_deposit(suite.owner.clone().as_ref()) - .unwrap(); + adapter.return_deposits().unwrap(); assert_eq!( - suite - .query_cw20_balance(suite.owner.as_ref(), &suite.default_cw20) - .unwrap(), - 1_000u128, + cw20.balance(mock.sender.to_string()).unwrap().balance, + Uint128::from(1_000_000u128), ); - // Tokens are sent back to the address specified in the sumbission. + // Tokens are sent back to submission sender, not recipient. assert_eq!( - suite - .query_cw20_balance(&recipient, &suite.default_cw20) - .unwrap(), - 0u128, + cw20.balance(recipient.to_string()).unwrap().balance, + Uint128::zero(), ); assert_eq!( - suite - .query_cw20_balance(suite.gauge_adapter.as_ref(), &suite.default_cw20) - .unwrap(), - 0u128, + cw20.balance(adapter.address().unwrap().to_string()) + .unwrap() + .balance, + Uint128::zero(), + ); +} + +#[test] +fn sample_gauge_msgs_cw20() { + let mock = MockBech32::new("mock"); + let addr_1 = mock.addr_make("addr1"); + let addr_2 = mock.addr_make("addr2"); + let addr_3 = mock.addr_make("addr3"); + let reward = Uint128::new(1_000_000); + let (adapter, cw20) = setup_cw20_reward_gauge_adapter(mock.clone(), None); + + adapter + .create_submission(addr_1.to_string(), "name".into(), "https://test.url".into()) + .unwrap(); + adapter + .create_submission(addr_2.to_string(), "name".into(), "https://test.url".into()) + .unwrap(); + + let selected = vec![ + (addr_1.to_string(), Decimal::percent(41)), + (addr_2.to_string(), Decimal::percent(33)), + (addr_3.to_string(), Decimal::percent(26)), + ]; + + let res: crate::msg::SampleGaugeMsgsResponse = + adapter.sample_gauge_msgs(selected.clone()).unwrap(); + assert_eq!(res.execute.len(), 3); + assert_eq!( + res.execute, + [ + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: cw20.addr_str().unwrap(), + msg: to_json_binary(&AbsCw20ExecuteMsg::Transfer { + recipient: addr_1.to_string(), + amount: reward * Decimal::percent(41) + }) + .unwrap(), + funds: vec![] + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: cw20.addr_str().unwrap(), + msg: to_json_binary(&AbsCw20ExecuteMsg::Transfer { + recipient: addr_2.to_string(), + amount: reward * Decimal::percent(33) + }) + .unwrap(), + funds: vec![] + }), + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: cw20.addr_str().unwrap(), + msg: to_json_binary(&AbsCw20ExecuteMsg::Transfer { + recipient: addr_3.to_string(), + amount: reward * Decimal::percent(26) + }) + .unwrap(), + funds: vec![] + }), + ] ); } diff --git a/contracts/gauges/gauge-adapter/src/multitest/suite.rs b/contracts/gauges/gauge-adapter/src/multitest/suite.rs index 53ac2b14d..a667097fd 100644 --- a/contracts/gauges/gauge-adapter/src/multitest/suite.rs +++ b/contracts/gauges/gauge-adapter/src/multitest/suite.rs @@ -1,332 +1,116 @@ -use cosmwasm_std::{to_json_binary, Addr, Binary, Coin, Uint128}; -use cw20::{BalanceResponse, Cw20QueryMsg}; -use cw20::{Cw20Coin, MinterResponse}; -use cw_multi_test::{App, AppResponse, ContractWrapper, Executor}; +use abstract_cw_plus_interface::cw20_base::Cw20Base; +use cosmwasm_std::{Addr, Coin, Uint128}; +use cw_orch::{interface, mock::cw_multi_test::AppResponse, prelude::*}; +use cw_orch_core::CwEnvError; -use anyhow::Result as AnyResult; -use cw20_base::msg::ExecuteMsg as Cw20BaseExecuteMsg; -use cw20_base::msg::InstantiateMsg as Cw20BaseInstantiateMsg; +use abstract_cw20::{Cw20Coin as AbsCw20Coin, MinterResponse}; -use crate::msg::CheckOptionResponse; use crate::{ - msg::{ - AdapterQueryMsg, AllOptionsResponse, AllSubmissionsResponse, ExecuteMsg, ReceiveMsg, - SubmissionResponse, - }, - state::{Asset, AssetType}, + contract::{execute, instantiate, migrate, query}, + msg::{AdapterQueryMsg as QueryMsg, AssetUnchecked, ExecuteMsg, InstantiateMsg, MigrateMsg}, }; -pub const NATIVE: &str = "juno"; -pub const CW20: &str = "wynd"; -pub const OWNER: &str = "owner"; - // Store the marketing gauge adapter contract and returns the code id. -fn store_gauge_adapter(app: &mut App) -> u64 { - let contract = Box::new(ContractWrapper::new_with_empty( - crate::contract::execute, - crate::contract::instantiate, - crate::contract::query, - )); - - app.store_code(contract) +#[interface(InstantiateMsg, ExecuteMsg, QueryMsg, MigrateMsg)] +pub struct GaugeAdapter; + +impl Uploadable for GaugeAdapter { + /// Return the path to the wasm file corresponding to the contract + fn wasm(_chain: &ChainInfoOwned) -> WasmPath { + artifacts_dir_from_workspace!() + .find_wasm_path("gauge_adapter") + .unwrap() + } + /// Returns a CosmWasm contract wrapper + fn wrapper() -> Box> { + Box::new(ContractWrapper::new_with_empty(execute, instantiate, query).with_migrate(migrate)) + } } -// Store the cw20 contract and returns the code id. -fn store_cw20(app: &mut App) -> u64 { - let contract = Box::new(ContractWrapper::new_with_empty( - cw20_base::contract::execute, - cw20_base::contract::instantiate, - cw20_base::contract::query, - )); - - app.store_code(contract) +pub fn setup_gauge_adapter( + mock: MockBech32, + required_deposit: Option, +) -> GaugeAdapter { + let adapter = GaugeAdapter::new("gauge_adapter", mock.clone()); + adapter.upload().unwrap(); + + let instantiate = InstantiateMsg { + admin: mock.sender_addr().to_string(), + required_deposit, + reward: AssetUnchecked::new_native("juno", 1_000_000), + community_pool: mock.addr_make("community_pool").to_string(), + }; + adapter.instantiate(&instantiate, None, None).unwrap(); + adapter } -#[derive(Debug)] -pub struct SuiteBuilder { - // Gauge adapter's instantiate params - community_pool: String, - required_deposit: Option, - reward: Asset, - funds: Vec<(Addr, Vec)>, - cw20_funds: Vec, +pub fn setup_cw20_reward_gauge_adapter( + mock: MockBech32, + required_deposit: Option, +) -> (GaugeAdapter, Cw20Base) { + let adapter = GaugeAdapter::new("gauge_adapter", mock.clone()); + adapter.upload().unwrap(); + let cw20 = cw20_helper(mock.clone()); + + let instantiate = InstantiateMsg { + admin: mock.sender_addr().to_string(), + required_deposit, + reward: AssetUnchecked::new_cw20(&cw20.addr_str().unwrap(), 1_000_000), + community_pool: mock.addr_make("community_pool").to_string(), + }; + adapter.instantiate(&instantiate, None, None).unwrap(); + (adapter, cw20) } -impl SuiteBuilder { - pub fn new() -> Self { - Self { - community_pool: "community".to_owned(), - required_deposit: None, - reward: Asset { - denom: AssetType::Native(NATIVE.into()), - amount: Uint128::new(1_000_000), +// +pub fn native_submission_helper( + adapter: GaugeAdapter, + sender: Addr, + recipient: Addr, + native_tokens: Option, +) -> Result { + if let Some(assets) = native_tokens.clone() { + adapter.call_as(&sender).execute( + &crate::msg::ExecuteMsg::CreateSubmission { + name: "DAOers".to_string(), + url: "https://daodao.zone".to_string(), + address: recipient.to_string(), }, - funds: vec![], - cw20_funds: vec![], - } - } - - pub fn with_community_pool(mut self, community_pool: &str) -> Self { - self.community_pool = community_pool.to_string(); - self - } - - // Allows to initialize the suite with native coins associated to an address. - pub fn with_funds(mut self, addr: &str, funds: &[Coin]) -> Self { - self.funds.push((Addr::unchecked(addr), funds.into())); - self - } - - // Allows to initialize the suite with default cw20 tokens associated to an address. - pub fn with_cw20_funds(mut self, addr: &str, amount: u128) -> Self { - self.cw20_funds.push(Cw20Coin { - address: addr.into(), - amount: Uint128::from(amount), - }); - self - } - - // Allows to initialize the marketing gauge adapter with required native coins in the config. - pub fn with_native_deposit(mut self, amount: u128) -> Self { - self.required_deposit = Some(Asset { - denom: AssetType::Native(NATIVE.into()), - amount: Uint128::from(amount), - }); - self - } - - // Allows to initialize the marketing gauge adapter with required cw20 tokens in the config. - pub fn with_cw20_deposit(mut self, amount: u128) -> Self { - self.required_deposit = Some(Asset { - denom: AssetType::Cw20("contract1".to_string()), - amount: Uint128::from(amount), - }); - self - } - - // Instantiate a marketing gauge adapter and returns its address. - fn instantiate_marketing_gauge_adapter( - &self, - app: &mut App, - gauge_id: u64, - owner: &str, - ) -> Addr { - app.instantiate_contract( - gauge_id, - Addr::unchecked(owner), - &crate::msg::InstantiateMsg { - admin: owner.to_owned(), - required_deposit: self.required_deposit.clone(), - community_pool: self.community_pool.clone(), - reward: self.reward.clone(), - }, - &[], - "Marketing Gauge Adapter", - None, + Some(&[assets]), ) - .unwrap() - } - - // Instantiate a cw20 and returns its address. - fn instantiate_default_cw20(&self, app: &mut App, cw20_code_id: u64, owner: &str) -> Addr { - app.instantiate_contract( - cw20_code_id, - Addr::unchecked(owner), - &Cw20BaseInstantiateMsg { - name: CW20.to_owned(), - symbol: CW20.to_owned(), - decimals: 6, - initial_balances: self.cw20_funds.clone(), - mint: Some(MinterResponse { - minter: owner.to_string(), - cap: None, - }), - marketing: None, + } else { + adapter.call_as(&sender).execute( + &crate::msg::ExecuteMsg::CreateSubmission { + name: "DAOers".to_string(), + url: "https://daodao.zone".to_string(), + address: recipient.to_string(), }, - &[], - CW20.to_owned(), None, ) - .unwrap() - } - - #[track_caller] - pub fn build(self) -> Suite { - let mut app = App::default(); - let owner = Addr::unchecked(OWNER); - - // Store required contracts. - let cw20_code_id = store_cw20(&mut app); - let gauge_adapter_code_id = store_gauge_adapter(&mut app); - - // Instantiate default contracts. - let gauge_adapter_addr = - self.instantiate_marketing_gauge_adapter(&mut app, gauge_adapter_code_id, OWNER); - - // cw20 address must be "contract1" otherwise cannot use `with_cw20_deposit()`. Not very - // elegant but do its job. - let cw20_addr = self.instantiate_default_cw20(&mut app, cw20_code_id, OWNER); - - // Mint initial native token if any. - app.init_modules(|router, _, storage| -> AnyResult<()> { - for (addr, coin) in self.funds { - router.bank.init_balance(storage, &addr, coin)?; - } - Ok(()) - }) - .unwrap(); - - Suite { - owner, - app, - gauge_adapter: gauge_adapter_addr, - default_cw20: cw20_addr, - cw20_code_id, - } } } -pub struct Suite { - pub owner: Addr, - pub app: App, - pub gauge_adapter: Addr, - pub default_cw20: Addr, - // This is stored to instantiate other cw20 tokens in tests. - cw20_code_id: u64, +pub fn cw20_helper(mock: MockBech32) -> Cw20Base { + let cw20 = Cw20Base::new("cw20", mock.clone()); + cw20.upload().unwrap(); + init_cw20(cw20.clone(), mock.sender.to_string()); + cw20 } -impl Suite { - // --------------------------------------------------------------------------------------------- - // Execute - // --------------------------------------------------------------------------------------------- - pub fn execute_create_submission( - &mut self, - sender: Addr, - name: String, - url: String, - address: String, - funds: &[Coin], - ) -> AnyResult { - self.app.execute_contract( - sender, - self.gauge_adapter.clone(), - &ExecuteMsg::CreateSubmission { name, url, address }, - funds, - ) - } - - pub fn execute_receive_through_cw20( - &mut self, - sender: Addr, - name: String, - url: String, - address: String, - // amount refers to CW20 amount. - amount: u128, - cw20_addr: Addr, - ) -> AnyResult { - let msg: Binary = to_json_binary(&ReceiveMsg::CreateSubmission { name, url, address })?; - - self.app.execute_contract( - sender, - cw20_addr, - &Cw20BaseExecuteMsg::Send { - contract: self.gauge_adapter.to_string(), - amount: Uint128::from(amount), - msg, - }, - &[], - ) - } - - pub fn execute_return_deposit(&mut self, sender: &str) -> AnyResult { - self.app.execute_contract( - Addr::unchecked(sender), - self.gauge_adapter.clone(), - &ExecuteMsg::ReturnDeposits {}, - &[], - ) - } - - // --------------------------------------------------------------------------------------------- - // Queries - // --------------------------------------------------------------------------------------------- - pub fn query_submission(&self, address: String) -> AnyResult { - Ok(self.app.wrap().query_wasm_smart( - self.gauge_adapter.clone(), - &AdapterQueryMsg::Submission { address }, - )?) - } - - pub fn query_submissions(&self) -> AnyResult> { - let res: AllSubmissionsResponse = self.app.wrap().query_wasm_smart( - self.gauge_adapter.clone(), - &AdapterQueryMsg::AllSubmissions {}, - )?; - - Ok(res.submissions) - } - - pub fn query_all_options(&self) -> AnyResult> { - let res: AllOptionsResponse = self - .app - .wrap() - .query_wasm_smart(self.gauge_adapter.clone(), &AdapterQueryMsg::AllOptions {})?; - - Ok(res.options) - } - - pub fn query_check_option(&self, option: String) -> AnyResult { - let res: CheckOptionResponse = self.app.wrap().query_wasm_smart( - self.gauge_adapter.clone(), - &AdapterQueryMsg::CheckOption { option }, - )?; - - Ok(res.valid) - } - - // --------------------------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------------------------- - - // Instantiate a cw20 token and assign to address a specific amount. - pub fn instantiate_token(&mut self, address: &str, token: &str, amount: u128) -> Addr { - self.app - .instantiate_contract( - self.cw20_code_id, - Addr::unchecked(address), - &Cw20BaseInstantiateMsg { - name: token.to_owned(), - symbol: token.to_owned(), - decimals: 6, - initial_balances: vec![Cw20Coin { - address: address.to_owned(), - amount: Uint128::from(amount), - }], - mint: None, - marketing: None, - }, - &[], - token, - None, - ) - .unwrap() - } - - pub fn query_cw20_balance(&self, user: &str, contract: &Addr) -> AnyResult { - let balance: BalanceResponse = self.app.wrap().query_wasm_smart( - contract, - &Cw20QueryMsg::Balance { - address: user.to_owned(), - }, - )?; - - Ok(balance.balance.into()) - } - - pub fn query_native_balance(&self, user: &str) -> AnyResult { - let balance = self.app.wrap().query_balance(user, NATIVE)?; - - Ok(balance.amount.into()) - } +pub fn init_cw20(cw20: Cw20Base, minter: String) -> String { + let init_msg = abstract_cw20_base::msg::InstantiateMsg { + name: "test".to_string(), + symbol: "TEST".to_string(), + decimals: 6u8, + initial_balances: vec![AbsCw20Coin { + address: minter.clone(), + amount: Uint128::from(1_000_000u128), + }], + mint: Some(MinterResponse { minter, cap: None }), + marketing: None, + }; + cw20.instantiate(&init_msg, None, None).unwrap(); + let addr = cw20.address().unwrap(); + println!("correct cw20 addr: {:#?}", addr.clone()); + addr.to_string() } diff --git a/contracts/gauges/gauge-adapter/src/state.rs b/contracts/gauges/gauge-adapter/src/state.rs index e2d836943..68bd19d12 100644 --- a/contracts/gauges/gauge-adapter/src/state.rs +++ b/contracts/gauges/gauge-adapter/src/state.rs @@ -1,5 +1,6 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{Addr, Uint128}; +use cw_denom::CheckedDenom; use cw_storage_plus::{Item, Map}; #[cw_serde] @@ -16,34 +17,12 @@ pub struct Config { pub const CONFIG: Item = Item::new("config"); -#[cw_serde] -pub enum AssetType { - Native(String), - Cw20(String), -} - #[cw_serde] pub struct Asset { - pub denom: AssetType, + pub denom: CheckedDenom, pub amount: Uint128, } -impl Asset { - pub fn new_native(denom: &str, amount: u128) -> Self { - Self { - denom: AssetType::Native(denom.to_owned()), - amount: amount.into(), - } - } - - pub fn new_cw20(denom: &str, amount: u128) -> Self { - Self { - denom: AssetType::Cw20(denom.to_owned()), - amount: amount.into(), - } - } -} - #[cw_serde] pub struct Submission { pub sender: Addr, @@ -51,5 +30,5 @@ pub struct Submission { pub url: String, } -// All submissions indexed by submition's fund destination address. +// All submissions mapped by fund destination address. pub const SUBMISSIONS: Map = Map::new("submissions"); diff --git a/contracts/gauges/gauge/README.md b/contracts/gauges/gauge/README.md index de8f2c035..ef87daf0c 100644 --- a/contracts/gauges/gauge/README.md +++ b/contracts/gauges/gauge/README.md @@ -24,13 +24,13 @@ and eventually stopping them if we don't need them anymore (to avoid extra write ## Gauge Functionality -A gauge is initialised with a set of options. Anyone with voting power may vote for any option at any time, -which is recorded, and also updates the tally. If they revote, it checks their last vote to reduce power on +A gauge is initialized with a set of options. Anyone with voting power may vote for any option at any time, +which is recorded, and also updates the tally. If they re-vote, it checks their last vote to reduce power on that before adding to the new one. When an "update hook" is triggered, it updates the voting power of that user's vote, while maintaining the same option. Either increasing or decreasing the tally for the given option as appropriate. Every epoch (eg 1/week), the current tally of the gauge is sampled, and some cut-off applies (top 20, min 0.5% of votes, etc). The resulting set is the "selected set" and the options along with -their relative vote counts (normalised to 1.0 = total votes within this set) is used to initiate some +their relative vote counts (normalized to 1.0 = total votes within this set) is used to initiate some action (eg. distribute reward tokens). ## Extensibility From f49abe776ac4a52af1d8da36dbef3ad3c7b40262 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Wed, 14 Aug 2024 17:40:09 -0400 Subject: [PATCH 11/12] debug --- .github/workflows/test_tube.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test_tube.yml b/.github/workflows/test_tube.yml index 3b1a584cb..e3b668025 100644 --- a/.github/workflows/test_tube.yml +++ b/.github/workflows/test_tube.yml @@ -17,6 +17,9 @@ jobs: - name: Checkout sources uses: actions/checkout@v3 + - name: List largest packages + run: dpkg-query -Wf '${Installed-Size}\t${Package}\n' | sort -n | tail -n 100 + - name: Install latest nightly toolchain uses: actions-rs/toolchain@v1 with: From f56a12910247c716fbe04a5995960c33958aea3d Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Wed, 14 Aug 2024 17:41:41 -0400 Subject: [PATCH 12/12] no --- .github/workflows/test_tube.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test_tube.yml b/.github/workflows/test_tube.yml index e3b668025..3b1a584cb 100644 --- a/.github/workflows/test_tube.yml +++ b/.github/workflows/test_tube.yml @@ -17,9 +17,6 @@ jobs: - name: Checkout sources uses: actions/checkout@v3 - - name: List largest packages - run: dpkg-query -Wf '${Installed-Size}\t${Package}\n' | sort -n | tail -n 100 - - name: Install latest nightly toolchain uses: actions-rs/toolchain@v1 with: