From e3a7644bb540d69c5335bc278c87812467f9aa4a Mon Sep 17 00:00:00 2001 From: dusan-maksimovic Date: Thu, 25 Apr 2024 14:38:30 +0200 Subject: [PATCH] tribute contract implementation & tests --- Cargo.toml | 2 +- contracts/atom_wars/Cargo.toml | 2 +- .../atom_wars/examples/atom_wars_schema.rs | 17 + contracts/atom_wars/src/lib.rs | 2 +- contracts/tribute/Cargo.toml | 34 + contracts/tribute/contract.rs | 191 ----- .../examples/tribute_schema.rs} | 2 +- contracts/tribute/src/contract.rs | 380 ++++++++++ contracts/tribute/src/error.rs | 8 + contracts/tribute/src/lib.rs | 11 + contracts/tribute/src/msg.rs | 28 + contracts/tribute/src/query.rs | 13 + contracts/tribute/src/state.rs | 25 + contracts/tribute/src/testing.rs | 688 ++++++++++++++++++ contracts/tribute/state.rs | 17 - 15 files changed, 1208 insertions(+), 212 deletions(-) create mode 100644 contracts/atom_wars/examples/atom_wars_schema.rs create mode 100644 contracts/tribute/Cargo.toml delete mode 100644 contracts/tribute/contract.rs rename contracts/{atom_wars/examples/schema.rs => tribute/examples/tribute_schema.rs} (89%) create mode 100644 contracts/tribute/src/contract.rs create mode 100644 contracts/tribute/src/error.rs create mode 100644 contracts/tribute/src/lib.rs create mode 100644 contracts/tribute/src/msg.rs create mode 100644 contracts/tribute/src/query.rs create mode 100644 contracts/tribute/src/state.rs create mode 100644 contracts/tribute/src/testing.rs delete mode 100644 contracts/tribute/state.rs diff --git a/Cargo.toml b/Cargo.toml index 45f7319..43ec4ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["contracts/atom_wars"] +members = ["contracts/atom_wars", "contracts/tribute"] [profile.release] opt-level = 3 diff --git a/contracts/atom_wars/Cargo.toml b/contracts/atom_wars/Cargo.toml index b2e0ee0..c3dc83c 100644 --- a/contracts/atom_wars/Cargo.toml +++ b/contracts/atom_wars/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "counter" +name = "atom-wars" version = "1.0.0" authors = ["Udit Gulati"] edition = "2018" diff --git a/contracts/atom_wars/examples/atom_wars_schema.rs b/contracts/atom_wars/examples/atom_wars_schema.rs new file mode 100644 index 0000000..cfa61e2 --- /dev/null +++ b/contracts/atom_wars/examples/atom_wars_schema.rs @@ -0,0 +1,17 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; + +use atom_wars::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); +} diff --git a/contracts/atom_wars/src/lib.rs b/contracts/atom_wars/src/lib.rs index fd93a9f..8116d05 100644 --- a/contracts/atom_wars/src/lib.rs +++ b/contracts/atom_wars/src/lib.rs @@ -6,7 +6,7 @@ mod state; pub use msg::{ExecuteMsg, InstantiateMsg}; pub use query::QueryMsg; -pub use state::Constants; +pub use state::{Constants, Proposal, Vote}; #[cfg(test)] mod testing; diff --git a/contracts/tribute/Cargo.toml b/contracts/tribute/Cargo.toml new file mode 100644 index 0000000..353184b --- /dev/null +++ b/contracts/tribute/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "tribute" +version = "1.0.0" +edition = "2018" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for quicker tests, cargo test --lib +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[dependencies] +cosmwasm-std = { version = "1.0.0-beta8", features = ["staking"] } +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } +snafu = { version = "0.6.3" } +thiserror = { version = "1.0.23" } +cw-storage-plus = { version = "0.13.2" } +cosmwasm-schema = { version = "1.0.0-beta8" } +atom-wars = { path = "../atom_wars" } + +[dev-dependencies] +cosmwasm-schema = { version = "1.0.0-beta8" } \ No newline at end of file diff --git a/contracts/tribute/contract.rs b/contracts/tribute/contract.rs deleted file mode 100644 index a6f2839..0000000 --- a/contracts/tribute/contract.rs +++ /dev/null @@ -1,191 +0,0 @@ -// These are just the methods that have to do with the tribute contract, extracted and pasted from the main atom wars contract -// after we decided to put tribute in its own contract. The methods are not complete and will need to be modified to work with the new contract. - -fn add_tribute( - deps: DepsMut, - env: Env, - info: MessageInfo, - round_id: u64, - proposal_id: u64, -) -> Result { - // Check that the round is currently ongoing - // TODO: change to contract query - let current_round_id = ROUND_ID.load(deps.storage)?; - if round_id != current_round_id { - return Err(ContractError::Std(StdError::generic_err( - "Round is not currently ongoing", - ))); - } - - // Check that the sender has sent funds - if info.funds.is_empty() { - return Err(ContractError::Std(StdError::generic_err( - "Must send funds to add tribute", - ))); - } - - // Check that the sender has only sent one type of coin for the tribute - if info.funds.len() != 1 { - return Err(ContractError::Std(StdError::generic_err( - "Must send exactly one coin", - ))); - } - - // Create tribute in TributeMap - let tribute_id = TRIBUTE_ID.load(deps.storage)?; - TRIBUTE_ID.save(deps.storage, &(tribute_id + 1))?; - let tribute = Tribute { - funds: info.funds[0].clone(), - depositor: info.sender.clone(), - refunded: false, - }; - TRIBUTE_MAP.save(deps.storage, (round_id, proposal_id, tribute_id), &tribute)?; - - Ok(Response::new().add_attribute("action", "add_tribute")) -} - -// ClaimTribute(round_id, prop_id): -// Check that the round is ended -// Check that the prop won -// Look up sender's vote for the round -// Check that the sender voted for the prop -// Check that the sender has not already claimed the tribute -// Divide sender's vote power by total power voting for the prop to figure out their percentage -// Use the sender's percentage to send them the right portion of the tribute -// Mark on the sender's vote that they claimed the tribute -fn claim_tribute( - deps: DepsMut, - env: Env, - info: MessageInfo, - round_id: u64, - tribute_id: u64, -) -> Result { - // Check that the sender has not already claimed the tribute using the TRIBUTE_CLAIMS map - if TRIBUTE_CLAIMS.may_load(deps.storage, (info.sender.clone(), tribute_id))? == Some(true) { - return Err(ContractError::Std(StdError::generic_err( - "Sender has already claimed the tribute", - ))); - } - - // Check that the round is ended - let current_round_id = ROUND_ID.load(deps.storage)?; - if round_id >= current_round_id { - return Err(ContractError::Std(StdError::generic_err( - "Round has not ended yet", - ))); - } - - // Look up sender's vote for the round, error if it cannot be found - let vote = VOTE_MAP.load(deps.storage, (round_id, info.sender.clone()))?; - - // Load the winning prop_id - let winning_prop_id = get_winning_prop(deps.as_ref(), round_id)?; - - // Check that the sender voted for the winning proposal - if winning_prop_id != vote.prop_id { - return Err(ContractError::Std(StdError::generic_err( - "Proposal did not win the last round", - ))); - } - - // Divide sender's vote power by the prop's power to figure out their percentage - let proposal = PROPOSAL_MAP.load(deps.storage, (round_id, vote.prop_id))?; - // TODO: percentage needs to be a decimal type - let percentage = vote.power / proposal.power; - - // Load the tribute and use the percentage to figure out how much of the tribute to send them - let tribute = TRIBUTE_MAP.load(deps.storage, (round_id, vote.prop_id, tribute_id))?; - let amount = Uint128::from(tribute.funds.amount * percentage); - - // Mark in the TRIBUTE_CLAIMS that the sender has claimed this tribute - TRIBUTE_CLAIMS.save(deps.storage, (info.sender.clone(), tribute_id), &true)?; - - // Send the tribute to the sender - Ok(Response::new() - .add_attribute("action", "claim_tribute") - .add_message(BankMsg::Send { - to_address: info.sender.to_string(), - // TODO: amount needs to be a Coin type- take calculated amount instead of entire tribute amount - amount: vec![Coin { - denom: tribute.funds.denom, - amount, - }], - })) -} - -// RefundTribute(round_id, prop_id, tribute_id): -// Check that the round is ended -// Check that the prop lost -// Check that the sender is the depositor of the tribute -// Check that the sender has not already refunded the tribute -// Send the tribute back to the sender -fn refund_tribute( - deps: DepsMut, - env: Env, - info: MessageInfo, - round_id: u64, - proposal_id: u64, - tribute_id: u64, -) -> Result { - // Check that the round is ended by checking that the round_id is not the current round - let current_round_id = ROUND_ID.load(deps.storage)?; - if round_id == current_round_id { - return Err(ContractError::Std(StdError::generic_err( - "Round has not ended yet", - ))); - } - - // Get the winning prop for the round - let winning_prop_id = get_winning_prop(deps.as_ref(), round_id)?; - - // Check that this prop lost - if winning_prop_id == proposal_id { - return Err(ContractError::Std(StdError::generic_err("Proposal won"))); - } - - // Load the tribute - let mut tribute = TRIBUTE_MAP.load(deps.storage, (round_id, proposal_id, tribute_id))?; - - // Check that the sender is the depositor of the tribute - if tribute.depositor != info.sender { - return Err(ContractError::Std(StdError::generic_err( - "Sender is not the depositor of the tribute", - ))); - } - - // Check that the sender has not already refunded the tribute - if tribute.refunded { - return Err(ContractError::Std(StdError::generic_err( - "Sender has already refunded the tribute", - ))); - } - - // Mark the tribute as refunded - tribute.refunded = true; - TRIBUTE_MAP.save(deps.storage, (round_id, proposal_id, tribute_id), &tribute)?; - - // Send the tribute back to the sender - Ok(Response::new() - .add_attribute("action", "refund_tribute") - .add_message(BankMsg::Send { - to_address: info.sender.to_string(), - amount: vec![tribute.funds], - })) -} - -#[cfg_attr(not(feature = "library"), entry_point)] -pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { - match msg { - QueryMsg::GetCount {} => query_count(deps), - } -} - -// pub fn query_winning_proposal(deps: Deps) -> StdResult { -// let winning_prop_id = get_winning_prop(deps)?; -// to_json_binary(&winning_prop_id) -// } - -pub fn query_count(_deps: Deps) -> StdResult { - let constant = CONSTANTS.load(_deps.storage)?; - to_json_binary(&(CountResponse { count: 0 })) -} diff --git a/contracts/atom_wars/examples/schema.rs b/contracts/tribute/examples/tribute_schema.rs similarity index 89% rename from contracts/atom_wars/examples/schema.rs rename to contracts/tribute/examples/tribute_schema.rs index 2669799..3dd87f2 100644 --- a/contracts/atom_wars/examples/schema.rs +++ b/contracts/tribute/examples/tribute_schema.rs @@ -3,7 +3,7 @@ use std::fs::create_dir_all; use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; -use counter::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use tribute::{ExecuteMsg, InstantiateMsg, QueryMsg}; fn main() { let mut out_dir = current_dir().unwrap(); diff --git a/contracts/tribute/src/contract.rs b/contracts/tribute/src/contract.rs new file mode 100644 index 0000000..9629251 --- /dev/null +++ b/contracts/tribute/src/contract.rs @@ -0,0 +1,380 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, Addr, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, + Order, Response, StdError, StdResult, +}; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg}; +use crate::query::QueryMsg; +use crate::state::{Config, Tribute, CONFIG, TRIBUTE_CLAIMS, TRIBUTE_ID, TRIBUTE_MAP}; +use atom_wars::{Proposal, QueryMsg as AtomWarsQueryMsg, Vote}; + +pub const DEFAULT_MAX_ENTRIES: usize = 100; + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let config = Config { + atom_wars_contract: deps.api.addr_validate(&msg.atom_wars_contract)?, + top_n_props_count: msg.top_n_props_count, + }; + + CONFIG.save(deps.storage, &config)?; + TRIBUTE_ID.save(deps.storage, &0)?; + + Ok(Response::new() + .add_attribute("action", "initialisation") + .add_attribute("sender", info.sender.clone())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::AddTribute { + tranche_id, + proposal_id, + } => add_tribute(deps, env, info, tranche_id, proposal_id), + ExecuteMsg::ClaimTribute { + round_id, + tranche_id, + tribute_id, + } => claim_tribute(deps, env, info, round_id, tranche_id, tribute_id), + ExecuteMsg::RefundTribute { + round_id, + tranche_id, + proposal_id, + tribute_id, + } => refund_tribute( + deps, + env, + info, + round_id, + proposal_id, + tranche_id, + tribute_id, + ), + } +} + +fn add_tribute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + tranche_id: u64, + proposal_id: u64, +) -> Result { + let atom_wars_contract = CONFIG.load(deps.storage)?.atom_wars_contract; + let current_round_id = query_current_round_id(&deps, &atom_wars_contract)?; + + // Check that the proposal exists + query_proposal( + &deps, + &atom_wars_contract, + current_round_id, + tranche_id, + proposal_id, + )?; + + // Check that the sender has sent funds + if info.funds.is_empty() { + return Err(ContractError::Std(StdError::generic_err( + "Must send funds to add tribute", + ))); + } + + // Check that the sender has only sent one type of coin for the tribute + if info.funds.len() != 1 { + return Err(ContractError::Std(StdError::generic_err( + "Must send exactly one coin", + ))); + } + + // Create tribute in TributeMap + let tribute_id = TRIBUTE_ID.load(deps.storage)?; + TRIBUTE_ID.save(deps.storage, &(tribute_id + 1))?; + let tribute = Tribute { + funds: info.funds[0].clone(), + depositor: info.sender.clone(), + refunded: false, + }; + TRIBUTE_MAP.save( + deps.storage, + ((current_round_id, tranche_id), proposal_id, tribute_id), + &tribute, + )?; + + Ok(Response::new() + .add_attribute("action", "add_tribute") + .add_attribute("depositor", info.sender.clone()) + .add_attribute("round_id", current_round_id.to_string()) + .add_attribute("tranche_id", tranche_id.to_string()) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("funds", info.funds[0].to_string())) +} + +// ClaimTribute(round_id, tranche_id, prop_id): +// Check that the round is ended +// Check that the prop won +// Look up sender's vote for the round +// Check that the sender voted for the prop +// Check that the sender has not already claimed the tribute +// Divide sender's vote power by total power voting for the prop to figure out their percentage +// Use the sender's percentage to send them the right portion of the tribute +// Mark on the sender's vote that they claimed the tribute +fn claim_tribute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + round_id: u64, + tranche_id: u64, + tribute_id: u64, +) -> Result { + // Check that the sender has not already claimed the tribute using the TRIBUTE_CLAIMS map + if TRIBUTE_CLAIMS.may_load(deps.storage, (info.sender.clone(), tribute_id))? == Some(true) { + return Err(ContractError::Std(StdError::generic_err( + "Sender has already claimed the tribute", + ))); + } + + // Check that the round is ended + let config = CONFIG.load(deps.storage)?; + let current_round_id = query_current_round_id(&deps, &config.atom_wars_contract)?; + + if round_id >= current_round_id { + return Err(ContractError::Std(StdError::generic_err( + "Round has not ended yet", + ))); + } + + // Look up sender's vote for the round, error if it cannot be found + let vote = query_user_vote( + &deps, + &config.atom_wars_contract, + round_id, + tranche_id, + info.sender.clone().to_string(), + )?; + + // Check that the sender voted for one of the top N proposals + if !is_top_n_proposal(&deps, &config, round_id, tranche_id, vote.prop_id)? { + return Err(ContractError::Std(StdError::generic_err( + "User voted for proposal outside of top N proposals", + ))); + } + + let proposal = query_proposal( + &deps, + &config.atom_wars_contract, + round_id, + tranche_id, + vote.prop_id, + )?; + + // Load the tribute and use the percentage to figure out how much of the tribute to send them + let tribute = TRIBUTE_MAP.load( + deps.storage, + ((round_id, tranche_id), vote.prop_id, tribute_id), + )?; + + // Divide sender's vote power by the prop's power to figure out their percentage + let percentage_fraction = (vote.power, proposal.power); + let amount = match tribute.funds.amount.checked_mul_floor(percentage_fraction) { + Ok(amount) => amount, + Err(_) => { + return Err(ContractError::Std(StdError::generic_err( + "Failed to compute users tribute share", + ))); + } + }; + + // Mark in the TRIBUTE_CLAIMS that the sender has claimed this tribute + TRIBUTE_CLAIMS.save(deps.storage, (info.sender.clone(), tribute_id), &true)?; + + // Send the tribute to the sender + Ok(Response::new() + .add_attribute("action", "claim_tribute") + .add_message(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![Coin { + denom: tribute.funds.denom, + amount, + }], + })) +} + +// RefundTribute(round_id, tranche_id, prop_id, tribute_id): +// Check that the round is ended +// Check that the prop lost +// Check that the sender is the depositor of the tribute +// Check that the sender has not already refunded the tribute +// Send the tribute back to the sender +fn refund_tribute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + round_id: u64, + proposal_id: u64, + tranche_id: u64, + tribute_id: u64, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // Check that the round is ended by checking that the round_id is not the current round + let current_round_id = query_current_round_id(&deps, &config.atom_wars_contract)?; + if round_id >= current_round_id { + return Err(ContractError::Std(StdError::generic_err( + "Round has not ended yet", + ))); + } + + if is_top_n_proposal(&deps, &config, round_id, tranche_id, proposal_id)? { + return Err(ContractError::Std(StdError::generic_err( + "Can't refund top N proposal", + ))); + } + + // Load the tribute + let mut tribute = TRIBUTE_MAP.load( + deps.storage, + ((round_id, tranche_id), proposal_id, tribute_id), + )?; + + // Check that the sender is the depositor of the tribute + if tribute.depositor != info.sender { + return Err(ContractError::Std(StdError::generic_err( + "Sender is not the depositor of the tribute", + ))); + } + + // Check that the sender has not already refunded the tribute + if tribute.refunded { + return Err(ContractError::Std(StdError::generic_err( + "Sender has already refunded the tribute", + ))); + } + + // Mark the tribute as refunded + tribute.refunded = true; + TRIBUTE_MAP.save( + deps.storage, + ((round_id, tranche_id), proposal_id, tribute_id), + &tribute, + )?; + + // Send the tribute back to the sender + Ok(Response::new() + .add_attribute("action", "refund_tribute") + .add_message(BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![tribute.funds], + })) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&CONFIG.load(deps.storage)?), + QueryMsg::ProposalTributes { + round_id, + tranche_id, + proposal_id, + } => to_json_binary(&query_proposal_tributes( + deps, + round_id, + tranche_id, + proposal_id, + )), + } +} + +pub fn query_proposal_tributes( + deps: Deps, + round_id: u64, + tranche_id: u64, + proposal_id: u64, +) -> Vec { + TRIBUTE_MAP + .prefix(((round_id, tranche_id), proposal_id)) + .range(deps.storage, None, None, Order::Ascending) + .map(|l| l.unwrap().1) + .take(DEFAULT_MAX_ENTRIES) + .collect() +} + +fn query_current_round_id(deps: &DepsMut, atom_wars_contract: &Addr) -> Result { + let current_round_id: u64 = deps + .querier + .query_wasm_smart(atom_wars_contract, &AtomWarsQueryMsg::CurrentRound {})?; + + Ok(current_round_id) +} + +fn query_proposal( + deps: &DepsMut, + atom_wars_contract: &Addr, + round_id: u64, + tranche_id: u64, + proposal_id: u64, +) -> Result { + let proposal: Proposal = deps.querier.query_wasm_smart( + atom_wars_contract, + &AtomWarsQueryMsg::Proposal { + round_id, + tranche_id, + proposal_id, + }, + )?; + + Ok(proposal) +} + +fn query_user_vote( + deps: &DepsMut, + atom_wars_contract: &Addr, + round_id: u64, + tranche_id: u64, + address: String, +) -> Result { + Ok(deps.querier.query_wasm_smart( + atom_wars_contract, + &AtomWarsQueryMsg::UserVote { + round_id, + tranche_id, + address, + }, + )?) +} + +fn is_top_n_proposal( + deps: &DepsMut, + config: &Config, + round_id: u64, + tranche_id: u64, + proposal_id: u64, +) -> Result { + let proposals: Vec = deps.querier.query_wasm_smart( + &config.atom_wars_contract, + &AtomWarsQueryMsg::TopNProposals { + round_id, + tranche_id, + number_of_proposals: config.top_n_props_count as usize, + }, + )?; + + for proposal in proposals { + if proposal.proposal_id == proposal_id { + return Ok(true); + } + } + + Ok(false) +} diff --git a/contracts/tribute/src/error.rs b/contracts/tribute/src/error.rs new file mode 100644 index 0000000..40dab9f --- /dev/null +++ b/contracts/tribute/src/error.rs @@ -0,0 +1,8 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), +} diff --git a/contracts/tribute/src/lib.rs b/contracts/tribute/src/lib.rs new file mode 100644 index 0000000..5f45171 --- /dev/null +++ b/contracts/tribute/src/lib.rs @@ -0,0 +1,11 @@ +pub mod contract; +mod error; +mod msg; +mod query; +mod state; + +pub use msg::{ExecuteMsg, InstantiateMsg}; +pub use query::QueryMsg; + +#[cfg(test)] +mod testing; diff --git a/contracts/tribute/src/msg.rs b/contracts/tribute/src/msg.rs new file mode 100644 index 0000000..d6691a1 --- /dev/null +++ b/contracts/tribute/src/msg.rs @@ -0,0 +1,28 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct InstantiateMsg { + pub atom_wars_contract: String, + pub top_n_props_count: u64, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + AddTribute { + tranche_id: u64, + proposal_id: u64, + }, + ClaimTribute { + round_id: u64, + tranche_id: u64, + tribute_id: u64, + }, + RefundTribute { + round_id: u64, + tranche_id: u64, + proposal_id: u64, + tribute_id: u64, + }, +} diff --git a/contracts/tribute/src/query.rs b/contracts/tribute/src/query.rs new file mode 100644 index 0000000..88bfd4d --- /dev/null +++ b/contracts/tribute/src/query.rs @@ -0,0 +1,13 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + Config {}, + ProposalTributes { + round_id: u64, + tranche_id: u64, + proposal_id: u64, + }, +} diff --git a/contracts/tribute/src/state.rs b/contracts/tribute/src/state.rs new file mode 100644 index 0000000..daf00a8 --- /dev/null +++ b/contracts/tribute/src/state.rs @@ -0,0 +1,25 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Coin}; +use cw_storage_plus::{Item, Map}; + +pub const CONFIG: Item = Item::new("config"); + +#[cw_serde] +pub struct Config { + pub atom_wars_contract: Addr, + pub top_n_props_count: u64, +} + +pub const TRIBUTE_ID: Item = Item::new("tribute_id"); + +// TRIBUTE_MAP: key((round_id, tranche_id), prop_id, tribute_id) -> Tribute +pub const TRIBUTE_MAP: Map<((u64, u64), u64, u64), Tribute> = Map::new("tribute_map"); +#[cw_serde] +pub struct Tribute { + pub depositor: Addr, + pub funds: Coin, + pub refunded: bool, +} + +// TRIBUTE_CLAIMS: key(sender_addr, tribute_id) -> bool +pub const TRIBUTE_CLAIMS: Map<(Addr, u64), bool> = Map::new("tribute_claims"); diff --git a/contracts/tribute/src/testing.rs b/contracts/tribute/src/testing.rs new file mode 100644 index 0000000..882c45f --- /dev/null +++ b/contracts/tribute/src/testing.rs @@ -0,0 +1,688 @@ +use crate::{ + contract::{execute, instantiate, query_proposal_tributes}, + ExecuteMsg, InstantiateMsg, +}; +use atom_wars::{Proposal, QueryMsg as AtomWarsQueryMsg, Vote}; +use cosmwasm_std::{ + from_json, + testing::{mock_dependencies, mock_env, mock_info}, + to_json_binary, Binary, ContractResult, QuerierResult, Response, SystemError, SystemResult, + Uint128, WasmQuery, +}; +use cosmwasm_std::{BankMsg, Coin, CosmosMsg}; + +pub fn get_instantiate_msg(atom_wars_contract: String) -> InstantiateMsg { + InstantiateMsg { + atom_wars_contract, + top_n_props_count: 10, + } +} + +const DEFAULT_DENOM: &str = "uatom"; +const ATOM_WARS_CONTRACT_ADDRESS: &str = "addr0000"; +const USER_ADDRESS_1: &str = "addr0001"; +const USER_ADDRESS_2: &str = "addr0002"; + +pub struct MockWasmQuerier { + atom_wars_contract: String, + current_round: u64, + proposal: Option, + user_vote: Option<(u64, u64, String, Vote)>, + top_n_proposals: Vec, +} + +impl MockWasmQuerier { + fn new( + atom_wars_contract: String, + current_round: u64, + proposal: Option, + user_vote: Option<(u64, u64, String, Vote)>, + top_n_proposals: Vec, + ) -> Self { + Self { + atom_wars_contract, + current_round, + proposal, + user_vote, + top_n_proposals, + } + } + + fn handler(&self, query: &WasmQuery) -> QuerierResult { + match query { + WasmQuery::Smart { contract_addr, msg } => { + if *contract_addr != self.atom_wars_contract { + return SystemResult::Err(SystemError::NoSuchContract { + addr: contract_addr.to_string(), + }); + } + + let response = match from_json(msg).unwrap() { + AtomWarsQueryMsg::CurrentRound {} => to_json_binary(&self.current_round), + AtomWarsQueryMsg::Proposal { + round_id, + tranche_id, + proposal_id, + } => { + let err = SystemResult::Err(SystemError::InvalidRequest { + error: "proposal couldn't be found".to_string(), + request: Binary(vec![]), + }); + + match &self.proposal { + Some(prop) => { + if prop.round_id == round_id + && prop.tranche_id == tranche_id + && prop.proposal_id == proposal_id + { + to_json_binary(&prop) + } else { + return err; + } + } + _ => return err, + } + } + AtomWarsQueryMsg::UserVote { + round_id, + tranche_id, + address, + } => { + let err = SystemResult::Err(SystemError::InvalidRequest { + error: "vote couldn't be found".to_string(), + request: Binary(vec![]), + }); + + match &self.user_vote { + Some(vote) => { + if vote.0 == round_id && vote.1 == tranche_id && vote.2 == address { + to_json_binary(&vote.3) + } else { + return err; + } + } + _ => return err, + } + } + AtomWarsQueryMsg::TopNProposals { + round_id: _, + tranche_id: _, + number_of_proposals: _, + } => to_json_binary(&self.top_n_proposals), + _ => panic!("unsupported query"), + }; + + SystemResult::Ok(ContractResult::Ok(response.unwrap())) + } + _ => SystemResult::Err(SystemError::UnsupportedRequest { + kind: "unsupported query type".to_string(), + }), + } + } +} + +struct AddTributeTestCase { + description: String, + // (tranche_id, proposal_id) + proposal_info: (u64, u64), + tributes_to_add: Vec>, + // (current_round_id, proposal_to_tribute) + mock_data: (u64, Option), + expected_success: bool, + expected_error_msg: String, +} + +struct ClaimTributeTestCase { + description: String, + // (round_id, tranche_id, proposal_id, tribute_id) + tribute_info: (u64, u64, u64, u64), + tribute_to_add: Vec, + // (add_tribute_round_id, claim_tribute_round_id, proposal, user_vote, top_n_proposals) + mock_data: ( + u64, + u64, + Option, + Option<(u64, u64, String, Vote)>, + Vec, + ), + expected_tribute_claim: u128, + expected_success: bool, + expected_error_msg: String, +} + +struct RefundTributeTestCase { + description: String, + // (round_id, tranche_id, proposal_id, tribute_id) + tribute_info: (u64, u64, u64, u64), + tribute_to_add: Vec, + // (add_tribute_round_id, refund_tribute_round_id, proposal, top_n_proposals) + mock_data: (u64, u64, Option, Vec), + tribute_refunder: Option, + expected_tribute_refund: u128, + expected_success: bool, + expected_error_msg: String, +} + +#[test] +fn add_tribute_test() { + let mock_proposal = Proposal { + round_id: 10, + tranche_id: 0, + proposal_id: 5, + covenant_params: "covenant params".to_string(), + executed: false, + power: Uint128::new(10000), + percentage: Uint128::zero(), + }; + + let test_cases: Vec = vec![ + AddTributeTestCase { + description: "happy path".to_string(), + proposal_info: (0, 5), + tributes_to_add: vec![ + vec![Coin::new(1000, DEFAULT_DENOM)], + vec![Coin::new(5000, DEFAULT_DENOM)], + ], + mock_data: (10, Some(mock_proposal.clone())), + expected_success: true, + expected_error_msg: String::new(), + }, + AddTributeTestCase { + description: "try adding tribute for non-existing proposal".to_string(), + proposal_info: (0, 5), + tributes_to_add: vec![vec![Coin::new(1000, DEFAULT_DENOM)]], + mock_data: (10, None), + expected_success: false, + expected_error_msg: "proposal couldn't be found".to_string(), + }, + AddTributeTestCase { + description: "try adding tribute without providing any funds".to_string(), + proposal_info: (0, 5), + tributes_to_add: vec![vec![]], + mock_data: (10, Some(mock_proposal.clone())), + expected_success: false, + expected_error_msg: "Must send funds to add tribute".to_string(), + }, + AddTributeTestCase { + description: "try adding tribute by providing more than one token".to_string(), + proposal_info: (0, 5), + tributes_to_add: vec![vec![ + Coin::new(1000, DEFAULT_DENOM), + Coin::new(1000, "stake"), + ]], + mock_data: (10, Some(mock_proposal.clone())), + expected_success: false, + expected_error_msg: "Must send exactly one coin".to_string(), + }, + ]; + + for test in test_cases { + println!("running test case: {}", test.description); + + let (mut deps, env, info) = ( + mock_dependencies(), + mock_env(), + mock_info(USER_ADDRESS_1, &[]), + ); + + let mock_querier = MockWasmQuerier::new( + ATOM_WARS_CONTRACT_ADDRESS.to_string(), + test.mock_data.0, + test.mock_data.1, + None, + vec![], + ); + deps.querier.update_wasm(move |q| mock_querier.handler(q)); + + let msg = get_instantiate_msg(ATOM_WARS_CONTRACT_ADDRESS.to_string()); + let res = instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + assert!(res.is_ok()); + + let tribute_payer = USER_ADDRESS_1; + + for tribute in &test.tributes_to_add { + let info = mock_info(tribute_payer, tribute); + let msg = ExecuteMsg::AddTribute { + tranche_id: test.proposal_info.0, + proposal_id: test.proposal_info.1, + }; + + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg); + if test.expected_success { + assert!(res.is_ok()); + } else { + assert!(res + .unwrap_err() + .to_string() + .contains(&test.expected_error_msg)) + } + } + + // If ExecuteMsg::AddTribute was supposed to fail, then there will be no tributes added + if !test.expected_success { + continue; + } + + let res = query_proposal_tributes( + deps.as_ref(), + test.mock_data.0, + test.proposal_info.0, + test.proposal_info.1, + ); + assert_eq!(test.tributes_to_add.len(), res.len()); + + for i in 0..test.tributes_to_add.len() - 1 { + let tribute = &test.tributes_to_add[i]; + assert_eq!(res[i].funds, tribute[0].clone()); + assert_eq!(res[i].depositor.to_string(), tribute_payer.to_string()); + assert_eq!(res[i].refunded, false); + } + } +} + +#[test] +fn claim_tribute_test() { + let mock_proposal = Proposal { + round_id: 10, + tranche_id: 0, + proposal_id: 5, + covenant_params: "covenant params".to_string(), + executed: false, + power: Uint128::new(10000), + percentage: Uint128::zero(), + }; + + let mock_top_n_proposals = vec![ + Proposal { + round_id: 10, + tranche_id: 0, + proposal_id: 5, + covenant_params: "covenant params".to_string(), + executed: false, + power: Uint128::new(10000), + percentage: Uint128::zero(), + }, + Proposal { + round_id: 10, + tranche_id: 0, + proposal_id: 6, + covenant_params: "covenant params".to_string(), + executed: false, + power: Uint128::new(10000), + percentage: Uint128::zero(), + }, + ]; + + let test_cases: Vec = vec![ + ClaimTributeTestCase { + description: "happy path".to_string(), + tribute_info: (10, 0, 5, 0), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: ( + 10, + 11, + Some(mock_proposal.clone()), + Some(( + 10, + 0, + USER_ADDRESS_2.to_string(), + Vote { + prop_id: 5, + power: Uint128::new(70), + }, + )), + mock_top_n_proposals.clone(), + ), + expected_tribute_claim: 7, // (70 / 10_000) * 1_000 + expected_success: true, + expected_error_msg: String::new(), + }, + ClaimTributeTestCase { + description: "try claim tribute for proposal in current round".to_string(), + tribute_info: (10, 0, 5, 0), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: (10, 10, Some(mock_proposal.clone()), None, vec![]), + expected_tribute_claim: 0, + expected_success: false, + expected_error_msg: "Round has not ended yet".to_string(), + }, + ClaimTributeTestCase { + description: "try claim tribute if user didn't vote at all".to_string(), + tribute_info: (10, 0, 5, 0), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: (10, 11, Some(mock_proposal.clone()), None, vec![]), + expected_tribute_claim: 0, + expected_success: false, + expected_error_msg: "vote couldn't be found".to_string(), + }, + ClaimTributeTestCase { + description: "try claim tribute if user didn't vote for top N proposal".to_string(), + tribute_info: (10, 0, 5, 0), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: ( + 10, + 11, + Some(mock_proposal.clone()), + Some(( + 10, + 0, + USER_ADDRESS_2.to_string(), + Vote { + prop_id: 7, + power: Uint128::new(70), + }, + )), + mock_top_n_proposals.clone(), + ), + expected_tribute_claim: 0, + expected_success: false, + expected_error_msg: "User voted for proposal outside of top N proposals".to_string(), + }, + ClaimTributeTestCase { + description: "try claim tribute for non existing tribute id".to_string(), + tribute_info: (10, 0, 5, 1), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: ( + 10, + 11, + Some(mock_proposal.clone()), + Some(( + 10, + 0, + USER_ADDRESS_2.to_string(), + Vote { + prop_id: 5, + power: Uint128::new(70), + }, + )), + mock_top_n_proposals.clone(), + ), + expected_tribute_claim: 0, + expected_success: false, + expected_error_msg: "Tribute not found".to_string(), + }, + ]; + + for test in test_cases { + println!("running test case: {}", test.description); + + let (mut deps, env, info) = ( + mock_dependencies(), + mock_env(), + mock_info(USER_ADDRESS_1, &[]), + ); + + let mock_querier = MockWasmQuerier::new( + ATOM_WARS_CONTRACT_ADDRESS.to_string(), + test.mock_data.0, + test.mock_data.2.clone(), + None, + vec![], + ); + deps.querier.update_wasm(move |q| mock_querier.handler(q)); + + let msg = get_instantiate_msg(ATOM_WARS_CONTRACT_ADDRESS.to_string()); + let res = instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + assert!(res.is_ok()); + + let tribute_payer = USER_ADDRESS_1; + let info = mock_info(tribute_payer, &test.tribute_to_add); + let msg = ExecuteMsg::AddTribute { + tranche_id: test.tribute_info.1, + proposal_id: test.tribute_info.2, + }; + + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg); + assert!(res.is_ok()); + + // Update the expected round so that the tribute can be claimed + let mock_querier = MockWasmQuerier::new( + ATOM_WARS_CONTRACT_ADDRESS.to_string(), + test.mock_data.1, + test.mock_data.2.clone(), + test.mock_data.3.clone(), + test.mock_data.4.clone(), + ); + deps.querier.update_wasm(move |q| mock_querier.handler(q)); + + let tribute_claimer = USER_ADDRESS_2; + let info = mock_info(tribute_claimer, &[]); + let msg = ExecuteMsg::ClaimTribute { + round_id: test.tribute_info.0, + tranche_id: test.tribute_info.1, + tribute_id: test.tribute_info.3, + }; + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + + if !test.expected_success { + assert!(res + .unwrap_err() + .to_string() + .contains(&test.expected_error_msg)); + continue; + } + + assert!(res.is_ok()); + let res = res.unwrap(); + assert_eq!(1, res.messages.len()); + + verify_tokens_received( + res, + &tribute_claimer.to_string(), + &test.tribute_to_add[0].denom, + test.expected_tribute_claim, + ); + + // Verify that the user can't claim the same tribute twice + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + assert!(res + .unwrap_err() + .to_string() + .contains("Sender has already claimed the tribute")) + } +} + +#[test] +fn refund_tribute_test() { + let mock_proposal = Proposal { + round_id: 10, + tranche_id: 0, + proposal_id: 5, + covenant_params: "covenant params".to_string(), + executed: false, + power: Uint128::new(10000), + percentage: Uint128::zero(), + }; + + let mock_top_n_proposals = vec![Proposal { + round_id: 10, + tranche_id: 0, + proposal_id: 6, + covenant_params: "covenant params".to_string(), + executed: false, + power: Uint128::new(10000), + percentage: Uint128::zero(), + }]; + + let test_cases: Vec = vec![ + RefundTributeTestCase { + description: "happy path".to_string(), + tribute_info: (10, 0, 5, 0), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: ( + 10, + 11, + Some(mock_proposal.clone()), + mock_top_n_proposals.clone(), + ), + tribute_refunder: None, + expected_tribute_refund: 1000, + expected_success: true, + expected_error_msg: String::new(), + }, + RefundTributeTestCase { + description: "try to get refund for the current round".to_string(), + tribute_info: (10, 0, 5, 0), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: ( + 10, + 10, + Some(mock_proposal.clone()), + mock_top_n_proposals.clone(), + ), + tribute_refunder: None, + expected_tribute_refund: 0, + expected_success: false, + expected_error_msg: "Round has not ended yet".to_string(), + }, + RefundTributeTestCase { + description: "try to get refund for the top N proposal".to_string(), + tribute_info: (10, 0, 5, 0), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: ( + 10, + 11, + Some(mock_proposal.clone()), + vec![mock_proposal.clone()], + ), + tribute_refunder: None, + expected_tribute_refund: 0, + expected_success: false, + expected_error_msg: "Can't refund top N proposal".to_string(), + }, + RefundTributeTestCase { + description: "try to get refund for non existing tribute".to_string(), + tribute_info: (10, 0, 5, 1), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: ( + 10, + 11, + Some(mock_proposal.clone()), + mock_top_n_proposals.clone(), + ), + tribute_refunder: None, + expected_tribute_refund: 0, + expected_success: false, + expected_error_msg: "Tribute not found".to_string(), + }, + RefundTributeTestCase { + description: "try to get refund if not the depositor".to_string(), + tribute_info: (10, 0, 5, 0), + tribute_to_add: vec![Coin::new(1000, DEFAULT_DENOM)], + mock_data: ( + 10, + 11, + Some(mock_proposal.clone()), + mock_top_n_proposals.clone(), + ), + tribute_refunder: Some(USER_ADDRESS_2.to_string()), + expected_tribute_refund: 0, + expected_success: false, + expected_error_msg: "Sender is not the depositor of the tribute".to_string(), + }, + ]; + + for test in test_cases { + println!("running test case: {}", test.description); + + let (mut deps, env, info) = ( + mock_dependencies(), + mock_env(), + mock_info(USER_ADDRESS_1, &[]), + ); + + let mock_querier = MockWasmQuerier::new( + ATOM_WARS_CONTRACT_ADDRESS.to_string(), + test.mock_data.0, + test.mock_data.2.clone(), + None, + vec![], + ); + deps.querier.update_wasm(move |q| mock_querier.handler(q)); + + let msg = get_instantiate_msg(ATOM_WARS_CONTRACT_ADDRESS.to_string()); + let res = instantiate(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + assert!(res.is_ok()); + + let tribute_payer = USER_ADDRESS_1; + let info = mock_info(tribute_payer, &test.tribute_to_add); + let msg = ExecuteMsg::AddTribute { + tranche_id: test.tribute_info.1, + proposal_id: test.tribute_info.2, + }; + + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg); + assert!(res.is_ok()); + + // Update the expected round so that the tribute can be refunded + let mock_querier = MockWasmQuerier::new( + ATOM_WARS_CONTRACT_ADDRESS.to_string(), + test.mock_data.1, + test.mock_data.2.clone(), + None, + test.mock_data.3.clone(), + ); + deps.querier.update_wasm(move |q| mock_querier.handler(q)); + + // If specified, try to use a different tribute refunder + let tribute_refunder = match test.tribute_refunder { + Some(refunder) => refunder, + None => tribute_payer.to_string(), + }; + + let info = mock_info(&tribute_refunder, &[]); + let msg = ExecuteMsg::RefundTribute { + round_id: test.tribute_info.0, + tranche_id: test.tribute_info.1, + proposal_id: test.tribute_info.2, + tribute_id: test.tribute_info.3, + }; + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + + if !test.expected_success { + assert!(res + .unwrap_err() + .to_string() + .contains(&test.expected_error_msg)); + continue; + } + + assert!(res.is_ok()); + let res = res.unwrap(); + assert_eq!(1, res.messages.len()); + + verify_tokens_received( + res, + &tribute_refunder, + &test.tribute_to_add[0].denom, + test.expected_tribute_refund, + ); + + // Verify that the user can't refund the same tribute twice + let res = execute(deps.as_mut(), env.clone(), info.clone(), msg.clone()); + assert!(res + .unwrap_err() + .to_string() + .contains("Sender has already refunded the tribute")) + } +} + +fn verify_tokens_received( + res: Response, + expected_receiver: &String, + expected_denom: &String, + expected_amount: u128, +) { + match &res.messages[0].msg { + CosmosMsg::Bank(bank_msg) => match bank_msg { + BankMsg::Send { to_address, amount } => { + assert_eq!(*expected_receiver, *to_address); + assert_eq!(1, amount.len()); + assert_eq!(*expected_denom, amount[0].denom); + assert_eq!(expected_amount, amount[0].amount.u128()); + } + _ => panic!("expected BankMsg::Send message"), + }, + _ => panic!("expected CosmosMsg::Bank msg"), + }; +} diff --git a/contracts/tribute/state.rs b/contracts/tribute/state.rs deleted file mode 100644 index 9448033..0000000 --- a/contracts/tribute/state.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub const TRIBUTE_ID: Item = Item::new("tribute_id"); - -// TRIBUTE_MAP: key(round_id, prop_id, tribute_id) -> Tribute { -// depositor: Address, -// funds: Coin, -// refunded: bool -// } -pub const TRIBUTE_MAP: Map<(u64, u64, u64), Tribute> = Map::new("tribute_map"); -#[cw_serde] -pub struct Tribute { - pub depositor: Addr, - pub funds: Coin, - pub refunded: bool, -} - -// TributeClaims: key(sender_addr, tribute_id) -> bool -pub const TRIBUTE_CLAIMS: Map<(Addr, u64), bool> = Map::new("tribute_claims");