From f263a6c51a99bc01485d78340f58da6724b17c1b Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sun, 2 Jul 2023 19:47:03 +0200 Subject: [PATCH 01/13] First draft of cw-bounties contract --- Cargo.lock | 16 ++ Cargo.toml | 1 + contracts/external/cw-bounties/Cargo.toml | 33 +++ contracts/external/cw-bounties/README.md | 7 + .../external/cw-bounties/examples/schema.rs | 11 + .../external/cw-bounties/src/contract.rs | 272 ++++++++++++++++++ contracts/external/cw-bounties/src/error.rs | 26 ++ contracts/external/cw-bounties/src/lib.rs | 11 + contracts/external/cw-bounties/src/msg.rs | 72 +++++ contracts/external/cw-bounties/src/state.rs | 37 +++ contracts/external/cw-bounties/src/tests.rs | 1 + 11 files changed, 487 insertions(+) create mode 100644 contracts/external/cw-bounties/Cargo.toml create mode 100644 contracts/external/cw-bounties/README.md create mode 100644 contracts/external/cw-bounties/examples/schema.rs create mode 100644 contracts/external/cw-bounties/src/contract.rs create mode 100644 contracts/external/cw-bounties/src/error.rs create mode 100644 contracts/external/cw-bounties/src/lib.rs create mode 100644 contracts/external/cw-bounties/src/msg.rs create mode 100644 contracts/external/cw-bounties/src/state.rs create mode 100644 contracts/external/cw-bounties/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 6128b4f1c..b60bb0d6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -677,6 +677,22 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw-bounties" +version = "2.3.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-multi-test", + "cw-ownable", + "cw-paginate-storage 2.3.0", + "cw-storage-plus 1.1.0", + "cw-utils 1.0.1", + "cw2 1.1.0", + "thiserror", +] + [[package]] name = "cw-controllers" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index a6701bf57..3aa82b2b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ wynd-utils = "0.4" cw-ownable = "0.5" cw-admin-factory = { path = "./contracts/external/cw-admin-factory", version = "2.4.1" } +cw-bounties = { path = "contracts/external/cw-bounties", version = "*" } cw-denom = { path = "./packages/cw-denom", version = "2.4.1" } cw-hooks = { path = "./packages/cw-hooks", version = "2.4.1" } cw-paginate-storage = { path = "./packages/cw-paginate-storage", version = "2.4.1" } diff --git a/contracts/external/cw-bounties/Cargo.toml b/contracts/external/cw-bounties/Cargo.toml new file mode 100644 index 000000000..7fe11c336 --- /dev/null +++ b/contracts/external/cw-bounties/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "cw-bounties" +authors = ["Jake Hartnell"] +description = "A CosmWasm contract for creating and managing on-chain bounties." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# For more explicit tests, `cargo test --features=backtraces`. +backtraces = ["cosmwasm-std/backtraces"] +# Use library feature to disable all instantiate/execute/query exports. +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-denom = { workspace = true } +cw-ownable = { workspace = true } +cw-paginate-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } +cw-multi-test = { workspace = true } diff --git a/contracts/external/cw-bounties/README.md b/contracts/external/cw-bounties/README.md new file mode 100644 index 000000000..2b17e5d47 --- /dev/null +++ b/contracts/external/cw-bounties/README.md @@ -0,0 +1,7 @@ +# cw-bounties + +A simple bounties smart contract. The contract is instantiated with an owner who controls when bounties are payed out (usually a DAO). + +On bounty creation the funds are taken, on update funds are added or removed and bounty details can be updated, on removal funds are returned to the bounties contract owner. + +Typical usage would involve a SubDAO with open proposal submission. Bounty hunters would be able to see a list of bounties, work on one and make a proposal to claim it. diff --git a/contracts/external/cw-bounties/examples/schema.rs b/contracts/external/cw-bounties/examples/schema.rs new file mode 100644 index 000000000..1ae5bead2 --- /dev/null +++ b/contracts/external/cw-bounties/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use cw_bounties::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/external/cw-bounties/src/contract.rs b/contracts/external/cw-bounties/src/contract.rs new file mode 100644 index 000000000..4a3c8b3da --- /dev/null +++ b/contracts/external/cw-bounties/src/contract.rs @@ -0,0 +1,272 @@ +use std::cmp::Ordering; + +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, Response, StdResult, +}; +use cw2::set_contract_version; +use cw_paginate_storage::paginate_map_values; +use cw_utils::must_pay; + +use crate::{ + error::ContractError, + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + state::{Bounty, BountyStatus, BOUNTIES, ID}, +}; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:cw-bounties"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let owner = deps.api.addr_validate(&msg.owner)?; + + // Set the contract owner + cw_ownable::initialize_owner(deps.storage, deps.api, Some(owner.as_str()))?; + + // Initialize the next ID + ID.save(deps.storage, &1)?; + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + // Only the owner can execute messages on this contract + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + match msg { + ExecuteMsg::Close { id } => close(deps, info, id), + ExecuteMsg::Create { + amount, + title, + description, + } => create(deps, env, info, amount, title, description), + ExecuteMsg::PayOut { id, recipient } => pay_out(deps, env, id, recipient), + ExecuteMsg::Update { + id, + amount, + title, + description, + } => update(deps, env, info, id, amount, title, description), + ExecuteMsg::UpdateOwnership(action) => update_owner(deps, info, env, action), + } +} + +pub fn create( + deps: DepsMut, + env: Env, + info: MessageInfo, + amount: Coin, + title: String, + description: Option, +) -> Result { + // Check funds sent match the bounty amount specified + let sent_amount = must_pay(&info, &amount.denom)?; + if sent_amount != amount.amount { + return Err(ContractError::InvalidAmount { + expected: amount.amount, + actual: sent_amount, + }); + }; + + // Check bounty title is not empty string + if title.is_empty() { + return Err(ContractError::EmptyTitle {}); + } + + // Increment and get the next bounty ID + let id = ID.update(deps.storage, |mut id| -> StdResult { + id += 1; + Ok(id) + })?; + + // Save the bounty + BOUNTIES.save( + deps.storage, + id, + &Bounty { + id, + amount, + title, + description, + status: BountyStatus::Open, + created_at: env.block.time.seconds(), + updated_at: None, + }, + )?; + + Ok(Response::default() + .add_attribute("action", "create_bounty") + .add_attribute("id", id.to_string())) +} + +pub fn close(deps: DepsMut, info: MessageInfo, id: u64) -> Result { + // Check bounty exists + let bounty = BOUNTIES.load(deps.storage, id)?; + + // Check bounty is open + if bounty.status != BountyStatus::Open { + return Err(ContractError::NotOpen {}); + }; + + // Pay out remaining funds to owner + // Only owner can call this, so sender is owner + let msg = BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![bounty.amount], + }; + + Ok(Response::default() + .add_message(msg) + .add_attribute("action", "close_bounty")) +} + +pub fn pay_out( + deps: DepsMut, + env: Env, + id: u64, + recipient: String, +) -> Result { + // Check bounty exists + let mut bounty = BOUNTIES.load(deps.storage, id)?; + + // Check bounty is open + if bounty.status != BountyStatus::Open { + return Err(ContractError::NotOpen {}); + } + + // Validate recipient address + deps.api.addr_validate(&recipient)?; + + // Set bounty status to claimed + bounty.status = BountyStatus::Claimed { + claimed_by: recipient.clone(), + claimed_at: env.block.time.seconds(), + }; + BOUNTIES.save(deps.storage, id, &bounty)?; + + // Message to pay out remaining funds to recipient + let msg = BankMsg::Send { + to_address: recipient.clone(), + amount: vec![bounty.clone().amount], + }; + + Ok(Response::new() + .add_message(msg) + .add_attribute("action", "pay_out_bounty") + .add_attribute("bounty_id", id.to_string()) + .add_attribute("amount", bounty.amount.to_string()) + .add_attribute("recipient", recipient)) +} + +pub fn update( + deps: DepsMut, + env: Env, + info: MessageInfo, + id: u64, + new_amount: Coin, + title: String, + description: Option, +) -> Result { + // Check bounty exists + let bounty = BOUNTIES.load(deps.storage, id)?; + + // Check bounty is open + if bounty.status != BountyStatus::Open { + return Err(ContractError::NotOpen {}); + } + + // Update bounty + BOUNTIES.save( + deps.storage, + bounty.id, + &Bounty { + id: bounty.id, + amount: new_amount.clone(), + title, + description, + status: bounty.status, + created_at: bounty.created_at, + updated_at: Some(env.block.time.seconds()), + }, + )?; + + // Check if amount is greater or less than original amount + let old_amount = bounty.amount; + let res = Response::new() + .add_attribute("action", "update_bounty") + .add_attribute("bounty_id", id.to_string()) + .add_attribute("amount", new_amount.amount.to_string()); + + match new_amount.amount.cmp(&old_amount.amount) { + Ordering::Greater => { + // If new amount is greater, check funds sent plus + // original amount match new amount + let sent_amount = must_pay(&info, &new_amount.denom)?; + if sent_amount + old_amount.amount != new_amount.amount { + return Err(ContractError::InvalidAmount { + expected: new_amount.amount - old_amount.amount, + actual: sent_amount + old_amount.amount, + }); + } + Ok(res) + } + Ordering::Less => { + // If new amount is less, pay out difference to owner + let diff = old_amount.amount - new_amount.amount; + let msg = BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![Coin { + denom: old_amount.denom, + amount: diff, + }], + }; + + Ok(res.add_message(msg)) + } + Ordering::Equal => { + // If the new amount hasn't changed we return the response + Ok(res) + } + } +} + +pub fn update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Bounty { id } => to_binary(&BOUNTIES.load(deps.storage, id)?), + QueryMsg::Bounties { start_after, limit } => to_binary(&paginate_map_values( + deps, + &BOUNTIES, + start_after, + limit, + Order::Descending, + )?), + QueryMsg::Count {} => to_binary(&ID.load(deps.storage)?), + QueryMsg::Ownership {} => to_binary(&cw_ownable::get_ownership(deps.storage)?), + } +} diff --git a/contracts/external/cw-bounties/src/error.rs b/contracts/external/cw-bounties/src/error.rs new file mode 100644 index 000000000..06dbaeca1 --- /dev/null +++ b/contracts/external/cw-bounties/src/error.rs @@ -0,0 +1,26 @@ +use cosmwasm_std::{StdError, Uint128}; +use cw_ownable::OwnershipError; +use cw_utils::PaymentError; +use thiserror::Error; + +#[derive(Error, Debug)] +#[cfg_attr(test, derive(PartialEq))] // Only neeed while testing. +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error(transparent)] + Ownable(#[from] OwnershipError), + + #[error("{0}")] + PaymentError(#[from] PaymentError), + + #[error("Title cannot be an empty string")] + EmptyTitle {}, + + #[error("Bounty is not open")] + NotOpen {}, + + #[error("Invalid amount. Expected ({expected}), got ({actual})")] + InvalidAmount { expected: Uint128, actual: Uint128 }, +} diff --git a/contracts/external/cw-bounties/src/lib.rs b/contracts/external/cw-bounties/src/lib.rs new file mode 100644 index 000000000..d1800adbc --- /dev/null +++ b/contracts/external/cw-bounties/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/external/cw-bounties/src/msg.rs b/contracts/external/cw-bounties/src/msg.rs new file mode 100644 index 000000000..747c11cee --- /dev/null +++ b/contracts/external/cw-bounties/src/msg.rs @@ -0,0 +1,72 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Coin; +use cw_ownable::cw_ownable_execute; + +#[cw_serde] +pub struct InstantiateMsg { + /// Contract owner with the ability to create, pay out, close + /// and update bounties. Must be a valid account address. + pub owner: String, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + /// Creates a bounty (only owner) + Create { + /// The amount the bounty is claimable for + amount: Coin, + /// The title of the bounty + title: String, + /// Bounty description and details + description: Option, + }, + /// Closes a bounty (only owner) + Close { + /// The ID of the bounty to close + id: u64, + }, + /// Claims a bounty (only owner) + PayOut { + /// Bounty id to claim + id: u64, + /// Recipient address where funds from bounty are claimed + recipient: String, + }, + /// Updates a bounty (only owner) + Update { + /// The ID of the bounty + id: u64, + /// The amount the bounty is claimable for + amount: Coin, + /// The title of the bounty + title: String, + /// Bounty description and details + description: Option, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns a single bounty by ID + #[returns(crate::state::Bounty)] + Bounty { id: u64 }, + /// List bounties + #[returns(Vec)] + Bounties { + /// Used for pagination + start_after: Option, + /// The number of bounties to return + limit: Option, + }, + /// Returns the number of bounties + #[returns(u64)] + Count {}, + /// Returns information about the current contract owner + #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] + Ownership {}, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/external/cw-bounties/src/state.rs b/contracts/external/cw-bounties/src/state.rs new file mode 100644 index 000000000..df683fc30 --- /dev/null +++ b/contracts/external/cw-bounties/src/state.rs @@ -0,0 +1,37 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Coin; +use cw_storage_plus::{Item, Map}; + +// TODO add timestamps +// A struct representing a bounty +#[cw_serde] +pub struct Bounty { + /// The ID for the bounty + pub id: u64, + /// The amount the bounty is claimable for + pub amount: Coin, + /// The title of the bounty + pub title: String, + /// Bounty description and details + pub description: Option, + /// The bounty status + pub status: BountyStatus, + /// The timestamp when the bounty was created + pub created_at: u64, + /// The timestamp when the bounty was last updated + pub updated_at: Option, +} + +/// The status of the bounty +#[cw_serde] +pub enum BountyStatus { + /// The bounty has been closed by the owner without being claimed + Closed { closed_at: u64 }, + /// The bounty has been claimed + Claimed { claimed_by: String, claimed_at: u64 }, + /// The bounty is open and available to be claimed + Open, +} + +pub const BOUNTIES: Map = Map::new("bounties"); +pub const ID: Item = Item::new("id"); diff --git a/contracts/external/cw-bounties/src/tests.rs b/contracts/external/cw-bounties/src/tests.rs new file mode 100644 index 000000000..70b786d12 --- /dev/null +++ b/contracts/external/cw-bounties/src/tests.rs @@ -0,0 +1 @@ +// TODO From 6195be261710014d6e3ea4fe06c8b59240db2982 Mon Sep 17 00:00:00 2001 From: mr-t Date: Fri, 7 Jul 2023 22:56:45 +0200 Subject: [PATCH 02/13] fixed: change status on close, added: consider different denom on update, test all of this (#720) Co-authored-by: Tai 'Mr. T' Truong --- Cargo.lock | 1 + contracts/external/cw-bounties/Cargo.toml | 1 + .../external/cw-bounties/src/contract.rs | 44 +- contracts/external/cw-bounties/src/tests.rs | 426 +++++++++++++++++- 4 files changed, 465 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b60bb0d6a..d83585909 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -681,6 +681,7 @@ dependencies = [ name = "cw-bounties" version = "2.3.0" dependencies = [ + "anyhow", "cosmwasm-schema", "cosmwasm-std", "cw-denom", diff --git a/contracts/external/cw-bounties/Cargo.toml b/contracts/external/cw-bounties/Cargo.toml index 7fe11c336..c7325d444 100644 --- a/contracts/external/cw-bounties/Cargo.toml +++ b/contracts/external/cw-bounties/Cargo.toml @@ -31,3 +31,4 @@ thiserror = { workspace = true } [dev-dependencies] cosmwasm-schema = { workspace = true } cw-multi-test = { workspace = true } +anyhow = { workspace = true } diff --git a/contracts/external/cw-bounties/src/contract.rs b/contracts/external/cw-bounties/src/contract.rs index 4a3c8b3da..503ce66ec 100644 --- a/contracts/external/cw-bounties/src/contract.rs +++ b/contracts/external/cw-bounties/src/contract.rs @@ -33,7 +33,7 @@ pub fn instantiate( cw_ownable::initialize_owner(deps.storage, deps.api, Some(owner.as_str()))?; // Initialize the next ID - ID.save(deps.storage, &1)?; + ID.save(deps.storage, &0)?; Ok(Response::default()) } @@ -49,7 +49,7 @@ pub fn execute( cw_ownable::assert_owner(deps.storage, &info.sender)?; match msg { - ExecuteMsg::Close { id } => close(deps, info, id), + ExecuteMsg::Close { id } => close(deps, env, info, id), ExecuteMsg::Create { amount, title, @@ -114,15 +114,25 @@ pub fn create( .add_attribute("id", id.to_string())) } -pub fn close(deps: DepsMut, info: MessageInfo, id: u64) -> Result { +pub fn close( + deps: DepsMut, + env: Env, + info: MessageInfo, + id: u64, +) -> Result { // Check bounty exists - let bounty = BOUNTIES.load(deps.storage, id)?; + let mut bounty = BOUNTIES.load(deps.storage, id)?; // Check bounty is open if bounty.status != BountyStatus::Open { return Err(ContractError::NotOpen {}); }; + bounty.status = BountyStatus::Closed { + closed_at: env.block.time.seconds(), + }; + BOUNTIES.save(deps.storage, id, &bounty)?; + // Pay out remaining funds to owner // Only owner can call this, so sender is owner let msg = BankMsg::Send { @@ -205,13 +215,35 @@ pub fn update( }, )?; - // Check if amount is greater or less than original amount - let old_amount = bounty.amount; let res = Response::new() .add_attribute("action", "update_bounty") .add_attribute("bounty_id", id.to_string()) .add_attribute("amount", new_amount.amount.to_string()); + // check if new amount has different denom + if new_amount.denom != bounty.amount.denom { + // If denom is different, check funds sent match new amount + let sent_amount = must_pay(&info, &new_amount.denom)?; + if sent_amount != new_amount.amount { + return Err(ContractError::InvalidAmount { + expected: new_amount.amount, + actual: sent_amount, + }); + } + // send back old amount + let msg = BankMsg::Send { + to_address: info.sender.to_string(), + amount: vec![Coin { + denom: bounty.amount.denom, + amount: bounty.amount.amount, + }], + }; + + return Ok(res.add_message(msg)); + }; + + // Check if amount is greater or less than original amount + let old_amount = bounty.amount; match new_amount.amount.cmp(&old_amount.amount) { Ordering::Greater => { // If new amount is greater, check funds sent plus diff --git a/contracts/external/cw-bounties/src/tests.rs b/contracts/external/cw-bounties/src/tests.rs index 70b786d12..7a12d2503 100644 --- a/contracts/external/cw-bounties/src/tests.rs +++ b/contracts/external/cw-bounties/src/tests.rs @@ -1 +1,425 @@ -// TODO +use cosmwasm_std::{coin, Addr, Coin, Empty, StdResult, Uint128}; +use cw_multi_test::{App, AppResponse, Contract, ContractWrapper, Executor}; +use cw_utils::PaymentError; + +use crate::{msg::InstantiateMsg, state::BountyStatus, ContractError}; + +pub struct Test { + pub app: App, + pub addr: Addr, + pub owner: Addr, + pub recipient: Addr, +} + +impl Test { + pub fn new() -> Self { + let owner = Addr::unchecked("owner"); + let recipient = Addr::unchecked("recipient"); + let mut app = App::new(|router, _, storage| { + router + .bank + .init_balance( + storage, + &owner, + vec![coin(10000, "ujuno"), coin(10000, "uatom")], + ) + .unwrap(); + router + .bank + .init_balance( + storage, + &recipient, + vec![coin(10000, "ujuno"), coin(10000, "uatom")], + ) + .unwrap(); + }); + let code_id = app.store_code(bounty_countract()); + let addr = app + .instantiate_contract( + code_id, + owner.clone(), + &InstantiateMsg { + owner: owner.to_string(), + }, + &[], + "cw-bounties", + None, + ) + .unwrap(); + Self { + app, + addr, + owner, + recipient, + } + } + + pub fn create( + &mut self, + amount: Coin, + title: String, + description: Option, + send_funds: &[Coin], + ) -> Result { + let msg = crate::msg::ExecuteMsg::Create { + amount, + title, + description, + }; + let res = + self.app + .execute_contract(self.owner.clone(), self.addr.clone(), &msg, send_funds)?; + Ok(res) + } + + pub fn close(&mut self, id: u64) -> Result { + let msg = crate::msg::ExecuteMsg::Close { id }; + let res = self + .app + .execute_contract(self.owner.clone(), self.addr.clone(), &msg, &[])?; + Ok(res) + } + + pub fn update( + &mut self, + id: u64, + amount: Coin, + title: String, + description: Option, + send_funds: &[Coin], + ) -> Result { + let msg = crate::msg::ExecuteMsg::Update { + id, + amount, + title, + description, + }; + let res = + self.app + .execute_contract(self.owner.clone(), self.addr.clone(), &msg, send_funds)?; + Ok(res) + } + + pub fn pay_out(&mut self, id: u64) -> Result { + let msg = crate::msg::ExecuteMsg::PayOut { + id, + recipient: self.recipient.to_string(), + }; + let res = self + .app + .execute_contract(self.owner.clone(), self.addr.clone(), &msg, &[])?; + Ok(res) + } + + pub fn query(&self, id: u64) -> StdResult { + let msg = crate::msg::QueryMsg::Bounty { id }; + self.app.wrap().query_wasm_smart(&self.addr, &msg) + } +} + +fn bounty_countract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +#[test] +pub fn test_create_bounty() { + let mut test = Test::new(); + + let balance_before = test + .app + .wrap() + .query_balance(test.owner.clone(), "ujuno") + .unwrap(); + // create bounty + test.create( + coin(100, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(100, "ujuno")], + ) + .unwrap(); + // assert balance + let balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), "ujuno") + .unwrap(); + assert!( + balance_before.amount.u128() == (balance_after.amount.u128() + 100), + "before: {}, after: {}", + balance_before.amount.u128(), + balance_after.amount.u128() + ); + + // create bounty without sending funds + let err: ContractError = test + .create( + coin(100, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::PaymentError(PaymentError::NoFunds {})); + + // create bounty with lower amount + let err: ContractError = test + .create( + coin(100, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(50, "ujuno")], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::InvalidAmount { + expected: Uint128::new(100), + actual: Uint128::new(50) + } + ); + + // create bounty with bigger amount + let err: ContractError = test + .create( + coin(100, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(150, "ujuno")], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::InvalidAmount { + expected: Uint128::new(100), + actual: Uint128::new(150) + } + ); +} + +#[test] +pub fn test_close_bounty() { + let mut test = Test::new(); + + // create bounty + test.create( + coin(100, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(100, "ujuno")], + ) + .unwrap(); + + // close bounty + test.close(1).unwrap(); + + // close bounty again + let err: ContractError = test.close(1).unwrap_err().downcast().unwrap(); + assert_eq!(err, ContractError::NotOpen {}); +} + +#[test] +pub fn test_update_bounty() { + let mut test = Test::new(); + + let initial_juno_balance = test + .app + .wrap() + .query_balance(test.owner.clone(), "ujuno") + .unwrap(); + let initial_atom_balance = test + .app + .wrap() + .query_balance(test.owner.clone(), "uatom") + .unwrap(); + // create bounty + test.create( + coin(100, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(100, "ujuno")], + ) + .unwrap(); + + // update bounty + test.update( + 1, + coin(200, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(100, "ujuno")], + ) + .unwrap(); + // assert balance + let balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), "ujuno") + .unwrap(); + assert!( + initial_juno_balance.amount.u128() == (balance_after.amount.u128() + 200), + "before: {}, after: {}", + initial_juno_balance.amount.u128(), + balance_after.amount.u128() + ); + + // update bounty without sending funds + let err: ContractError = test + .update( + 1, + coin(300, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::PaymentError(PaymentError::NoFunds {})); + + // update bounty with lower amount + let err: ContractError = test + .update( + 1, + coin(300, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(50, "ujuno")], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::InvalidAmount { + expected: Uint128::new(100), + actual: Uint128::new(250) + } + ); + + // update bounty with bigger amount + let err: ContractError = test + .update( + 1, + coin(300, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(150, "ujuno")], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::InvalidAmount { + expected: Uint128::new(100), + actual: Uint128::new(350) + } + ); + + // update bounty with different denom + test.update( + 1, + coin(200, "uatom"), + "title".to_string(), + Some("description".to_string()), + &[coin(200, "uatom")], + ) + .unwrap(); + // assert juno balance + let juno_balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), "ujuno") + .unwrap(); + assert!( + juno_balance_after.amount == initial_juno_balance.amount, + "before: {}, after: {}", + initial_juno_balance.amount.u128(), + balance_after.amount.u128() + ); + let atom_balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), "uatom") + .unwrap(); + assert!( + atom_balance_after.amount.u128() == (initial_atom_balance.amount.u128() - 200), + "before: {}, after: {}", + initial_atom_balance.amount.u128(), + atom_balance_after.amount.u128() + ); + + // test closed bounty + test.close(1).unwrap(); + let err: ContractError = test + .update( + 1, + coin(200, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(200, "ujuno")], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::NotOpen {}); +} + +#[test] +pub fn test_pay_out_bounty() { + let mut test = Test::new(); + + // create bounty + test.create( + coin(100, "ujuno"), + "title".to_string(), + Some("description".to_string()), + &[coin(100, "ujuno")], + ) + .unwrap(); + + let initial_juno_balance = test + .app + .wrap() + .query_balance(test.recipient.clone(), "ujuno") + .unwrap(); + // pay out bounty + test.pay_out(1).unwrap(); + // assert balance + let balance_after = test + .app + .wrap() + .query_balance(test.recipient.clone(), "ujuno") + .unwrap(); + assert!( + initial_juno_balance.amount.u128() + 100 == (balance_after.amount.u128()), + "before: {}, after: {}", + initial_juno_balance.amount.u128(), + balance_after.amount.u128() + ); + // assert bounty claimed + let bounty = test.query(1).unwrap(); + assert_eq!( + bounty.status, + BountyStatus::Claimed { + claimed_by: test.recipient.to_string(), + claimed_at: test.app.block_info().time.seconds() + } + ); + + // test bounty already claimed + let err: ContractError = test.pay_out(1).unwrap_err().downcast().unwrap(); + assert_eq!(err, ContractError::NotOpen {}); +} From 3e7d91d2983fca341618c063e54a52996b73be20 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Tue, 4 Jul 2023 15:38:37 +0200 Subject: [PATCH 03/13] Add note for future work --- contracts/external/cw-bounties/src/msg.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/external/cw-bounties/src/msg.rs b/contracts/external/cw-bounties/src/msg.rs index 747c11cee..01e30836d 100644 --- a/contracts/external/cw-bounties/src/msg.rs +++ b/contracts/external/cw-bounties/src/msg.rs @@ -33,6 +33,7 @@ pub enum ExecuteMsg { /// Recipient address where funds from bounty are claimed recipient: String, }, + /// TODO make vesting payout? /// Updates a bounty (only owner) Update { /// The ID of the bounty From 4e77dc99d4cb49e2f87f7479e851611466c130b5 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Sat, 8 Jul 2023 03:10:19 +0200 Subject: [PATCH 04/13] Schema --- contracts/external/cw-bounties/Cargo.toml | 1 - .../cw-bounties/schema/cw-bounties.json | 827 ++++++++++++++++++ 2 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 contracts/external/cw-bounties/schema/cw-bounties.json diff --git a/contracts/external/cw-bounties/Cargo.toml b/contracts/external/cw-bounties/Cargo.toml index c7325d444..b589b3df2 100644 --- a/contracts/external/cw-bounties/Cargo.toml +++ b/contracts/external/cw-bounties/Cargo.toml @@ -7,7 +7,6 @@ license = { workspace = true } repository = { workspace = true } version = { workspace = true } - [lib] crate-type = ["cdylib", "rlib"] diff --git a/contracts/external/cw-bounties/schema/cw-bounties.json b/contracts/external/cw-bounties/schema/cw-bounties.json new file mode 100644 index 000000000..c25fd5419 --- /dev/null +++ b/contracts/external/cw-bounties/schema/cw-bounties.json @@ -0,0 +1,827 @@ +{ + "contract_name": "cw-bounties", + "contract_version": "2.1.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "owner" + ], + "properties": { + "owner": { + "description": "Contract owner with the ability to create, pay out, close and update bounties. Must be a valid account address.", + "type": "string" + } + }, + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Creates a bounty (only owner)", + "type": "object", + "required": [ + "create" + ], + "properties": { + "create": { + "type": "object", + "required": [ + "amount", + "title" + ], + "properties": { + "amount": { + "description": "The amount the bounty is claimable for", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "description": { + "description": "Bounty description and details", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "The title of the bounty", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Closes a bounty (only owner)", + "type": "object", + "required": [ + "close" + ], + "properties": { + "close": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "The ID of the bounty to close", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Claims a bounty (only owner)", + "type": "object", + "required": [ + "pay_out" + ], + "properties": { + "pay_out": { + "type": "object", + "required": [ + "id", + "recipient" + ], + "properties": { + "id": { + "description": "Bounty id to claim", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "recipient": { + "description": "Recipient address where funds from bounty are claimed", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "TODO make vesting payout? Updates a bounty (only owner)", + "type": "object", + "required": [ + "update" + ], + "properties": { + "update": { + "type": "object", + "required": [ + "amount", + "id", + "title" + ], + "properties": { + "amount": { + "description": "The amount the bounty is claimable for", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "description": { + "description": "Bounty description and details", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "The ID of the bounty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "title": { + "description": "The title of the bounty", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update the contract's ownership. The `action` to be provided can be either to propose transferring ownership to an account, accept a pending ownership transfer, or renounce the ownership permanently.", + "type": "object", + "required": [ + "update_ownership" + ], + "properties": { + "update_ownership": { + "$ref": "#/definitions/Action" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Action": { + "description": "Actions that can be taken to alter the contract's ownership", + "oneOf": [ + { + "description": "Propose to transfer the contract's ownership to another account, optionally with an expiry time.\n\nCan only be called by the contract's current owner.\n\nAny existing pending ownership transfer is overwritten.", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "object", + "required": [ + "new_owner" + ], + "properties": { + "expiry": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "new_owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Accept the pending ownership transfer.\n\nCan only be called by the pending owner.", + "type": "string", + "enum": [ + "accept_ownership" + ] + }, + { + "description": "Give up the contract's ownership and the possibility of appointing a new owner.\n\nCan only be invoked by the contract's current owner.\n\nAny existing pending ownership transfer is canceled.", + "type": "string", + "enum": [ + "renounce_ownership" + ] + } + ] + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Returns a single bounty by ID", + "type": "object", + "required": [ + "bounty" + ], + "properties": { + "bounty": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "List bounties", + "type": "object", + "required": [ + "bounties" + ], + "properties": { + "bounties": { + "type": "object", + "properties": { + "limit": { + "description": "The number of bounties to return", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "Used for pagination", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the number of bounties", + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns information about the current contract owner", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "bounties": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_Bounty", + "type": "array", + "items": { + "$ref": "#/definitions/Bounty" + }, + "definitions": { + "Bounty": { + "type": "object", + "required": [ + "amount", + "created_at", + "id", + "status", + "title" + ], + "properties": { + "amount": { + "description": "The amount the bounty is claimable for", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "created_at": { + "description": "The timestamp when the bounty was created", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "Bounty description and details", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "The ID for the bounty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "description": "The bounty status", + "allOf": [ + { + "$ref": "#/definitions/BountyStatus" + } + ] + }, + "title": { + "description": "The title of the bounty", + "type": "string" + }, + "updated_at": { + "description": "The timestamp when the bounty was last updated", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "BountyStatus": { + "description": "The status of the bounty", + "oneOf": [ + { + "description": "The bounty has been closed by the owner without being claimed", + "type": "object", + "required": [ + "closed" + ], + "properties": { + "closed": { + "type": "object", + "required": [ + "closed_at" + ], + "properties": { + "closed_at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The bounty has been claimed", + "type": "object", + "required": [ + "claimed" + ], + "properties": { + "claimed": { + "type": "object", + "required": [ + "claimed_at", + "claimed_by" + ], + "properties": { + "claimed_at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "claimed_by": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The bounty is open and available to be claimed", + "type": "string", + "enum": [ + "open" + ] + } + ] + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "bounty": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Bounty", + "type": "object", + "required": [ + "amount", + "created_at", + "id", + "status", + "title" + ], + "properties": { + "amount": { + "description": "The amount the bounty is claimable for", + "allOf": [ + { + "$ref": "#/definitions/Coin" + } + ] + }, + "created_at": { + "description": "The timestamp when the bounty was created", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "description": { + "description": "Bounty description and details", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "The ID for the bounty", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "status": { + "description": "The bounty status", + "allOf": [ + { + "$ref": "#/definitions/BountyStatus" + } + ] + }, + "title": { + "description": "The title of the bounty", + "type": "string" + }, + "updated_at": { + "description": "The timestamp when the bounty was last updated", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "BountyStatus": { + "description": "The status of the bounty", + "oneOf": [ + { + "description": "The bounty has been closed by the owner without being claimed", + "type": "object", + "required": [ + "closed" + ], + "properties": { + "closed": { + "type": "object", + "required": [ + "closed_at" + ], + "properties": { + "closed_at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The bounty has been claimed", + "type": "object", + "required": [ + "claimed" + ], + "properties": { + "claimed": { + "type": "object", + "required": [ + "claimed_at", + "claimed_by" + ], + "properties": { + "claimed_at": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "claimed_by": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The bounty is open and available to be claimed", + "type": "string", + "enum": [ + "open" + ] + } + ] + }, + "Coin": { + "type": "object", + "required": [ + "amount", + "denom" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "denom": { + "type": "string" + } + } + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "count": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "uint64", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_Addr", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "pending_expiry": { + "description": "The deadline for the pending owner to accept the ownership. `None` if there isn't a pending ownership transfer, or if a transfer exists and it doesn't have a deadline.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "pending_owner": { + "description": "The account who has been proposed to take over the ownership. `None` if there isn't a pending ownership transfer.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + } + } +} From b80c3d24094c85a5f8ccae4c2ff4a35f1947b02d Mon Sep 17 00:00:00 2001 From: mr-t Date: Mon, 10 Jul 2023 16:05:35 +0200 Subject: [PATCH 05/13] add author (#722) Co-authored-by: Tai 'Mr. T' Truong --- contracts/external/cw-bounties/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/external/cw-bounties/Cargo.toml b/contracts/external/cw-bounties/Cargo.toml index b589b3df2..dca456e4b 100644 --- a/contracts/external/cw-bounties/Cargo.toml +++ b/contracts/external/cw-bounties/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cw-bounties" -authors = ["Jake Hartnell"] +authors = ["Jake Hartnell", "Mr T "] description = "A CosmWasm contract for creating and managing on-chain bounties." edition = { workspace = true } license = { workspace = true } From 9b846b585a054b150e7b1e41b70519d593fb4d81 Mon Sep 17 00:00:00 2001 From: mr-t Date: Wed, 30 Aug 2023 04:22:15 +0200 Subject: [PATCH 06/13] fix bug on bounty update (#730) * check Bounty is correct in all tests * fix update bounty with lower amount and owner accidentally send funds * cleanup and test update with sending wrong denom --- .../external/cw-bounties/src/contract.rs | 8 +- contracts/external/cw-bounties/src/tests.rs | 646 +++++++++++++----- 2 files changed, 463 insertions(+), 191 deletions(-) diff --git a/contracts/external/cw-bounties/src/contract.rs b/contracts/external/cw-bounties/src/contract.rs index 503ce66ec..181e65839 100644 --- a/contracts/external/cw-bounties/src/contract.rs +++ b/contracts/external/cw-bounties/src/contract.rs @@ -7,7 +7,7 @@ use cosmwasm_std::{ }; use cw2::set_contract_version; use cw_paginate_storage::paginate_map_values; -use cw_utils::must_pay; +use cw_utils::{may_pay, must_pay}; use crate::{ error::ContractError, @@ -243,7 +243,7 @@ pub fn update( }; // Check if amount is greater or less than original amount - let old_amount = bounty.amount; + let old_amount = bounty.amount.clone(); match new_amount.amount.cmp(&old_amount.amount) { Ordering::Greater => { // If new amount is greater, check funds sent plus @@ -259,7 +259,9 @@ pub fn update( } Ordering::Less => { // If new amount is less, pay out difference to owner - let diff = old_amount.amount - new_amount.amount; + // in case owner accidentally sent funds, send back as well + let funds_send = may_pay(&info, &bounty.amount.denom)?; + let diff = old_amount.amount - new_amount.amount + funds_send; let msg = BankMsg::Send { to_address: info.sender.to_string(), amount: vec![Coin { diff --git a/contracts/external/cw-bounties/src/tests.rs b/contracts/external/cw-bounties/src/tests.rs index 7a12d2503..111ffd523 100644 --- a/contracts/external/cw-bounties/src/tests.rs +++ b/contracts/external/cw-bounties/src/tests.rs @@ -2,15 +2,22 @@ use cosmwasm_std::{coin, Addr, Coin, Empty, StdResult, Uint128}; use cw_multi_test::{App, AppResponse, Contract, ContractWrapper, Executor}; use cw_utils::PaymentError; -use crate::{msg::InstantiateMsg, state::BountyStatus, ContractError}; +use crate::{ + msg::InstantiateMsg, + state::{Bounty, BountyStatus}, + ContractError, +}; pub struct Test { pub app: App, - pub addr: Addr, + pub contract: Addr, pub owner: Addr, pub recipient: Addr, } +const ATOM_DENOM: &str = "uatom"; +const JUNO_DENOM: &str = "ujuno"; + impl Test { pub fn new() -> Self { let owner = Addr::unchecked("owner"); @@ -21,7 +28,7 @@ impl Test { .init_balance( storage, &owner, - vec![coin(10000, "ujuno"), coin(10000, "uatom")], + vec![coin(10000, JUNO_DENOM), coin(10000, ATOM_DENOM)], ) .unwrap(); router @@ -29,12 +36,12 @@ impl Test { .init_balance( storage, &recipient, - vec![coin(10000, "ujuno"), coin(10000, "uatom")], + vec![coin(10000, JUNO_DENOM), coin(10000, ATOM_DENOM)], ) .unwrap(); }); let code_id = app.store_code(bounty_countract()); - let addr = app + let contract = app .instantiate_contract( code_id, owner.clone(), @@ -48,7 +55,7 @@ impl Test { .unwrap(); Self { app, - addr, + contract, owner, recipient, } @@ -66,17 +73,20 @@ impl Test { title, description, }; - let res = - self.app - .execute_contract(self.owner.clone(), self.addr.clone(), &msg, send_funds)?; + let res = self.app.execute_contract( + self.owner.clone(), + self.contract.clone(), + &msg, + send_funds, + )?; Ok(res) } pub fn close(&mut self, id: u64) -> Result { let msg = crate::msg::ExecuteMsg::Close { id }; - let res = self - .app - .execute_contract(self.owner.clone(), self.addr.clone(), &msg, &[])?; + let res = + self.app + .execute_contract(self.owner.clone(), self.contract.clone(), &msg, &[])?; Ok(res) } @@ -94,9 +104,12 @@ impl Test { title, description, }; - let res = - self.app - .execute_contract(self.owner.clone(), self.addr.clone(), &msg, send_funds)?; + let res = self.app.execute_contract( + self.owner.clone(), + self.contract.clone(), + &msg, + send_funds, + )?; Ok(res) } @@ -105,15 +118,15 @@ impl Test { id, recipient: self.recipient.to_string(), }; - let res = self - .app - .execute_contract(self.owner.clone(), self.addr.clone(), &msg, &[])?; + let res = + self.app + .execute_contract(self.owner.clone(), self.contract.clone(), &msg, &[])?; Ok(res) } pub fn query(&self, id: u64) -> StdResult { let msg = crate::msg::QueryMsg::Bounty { id }; - self.app.wrap().query_wasm_smart(&self.addr, &msg) + self.app.wrap().query_wasm_smart(&self.contract, &msg) } } @@ -130,27 +143,42 @@ fn bounty_countract() -> Box> { pub fn test_create_bounty() { let mut test = Test::new(); + let bounty_amount = 100; let balance_before = test .app .wrap() - .query_balance(test.owner.clone(), "ujuno") + .query_balance(test.owner.clone(), JUNO_DENOM) .unwrap(); // create bounty test.create( - coin(100, "ujuno"), + coin(bounty_amount, JUNO_DENOM), "title".to_string(), Some("description".to_string()), - &[coin(100, "ujuno")], + &[coin(bounty_amount, JUNO_DENOM)], ) .unwrap(); + // - assert bounty + let bounty = test.query(1).unwrap(); + assert_eq!( + bounty, + Bounty { + id: 1, + amount: coin(bounty_amount, JUNO_DENOM), + title: "title".to_string(), + description: Some("description".to_string()), + status: BountyStatus::Open, + created_at: test.app.block_info().time.seconds(), + updated_at: None, + } + ); // assert balance let balance_after = test .app .wrap() - .query_balance(test.owner.clone(), "ujuno") + .query_balance(test.owner.clone(), JUNO_DENOM) .unwrap(); assert!( - balance_before.amount.u128() == (balance_after.amount.u128() + 100), + balance_before.amount.u128() == (balance_after.amount.u128() + bounty_amount), "before: {}, after: {}", balance_before.amount.u128(), balance_after.amount.u128() @@ -159,7 +187,7 @@ pub fn test_create_bounty() { // create bounty without sending funds let err: ContractError = test .create( - coin(100, "ujuno"), + coin(bounty_amount, JUNO_DENOM), "title".to_string(), Some("description".to_string()), &[], @@ -172,10 +200,10 @@ pub fn test_create_bounty() { // create bounty with lower amount let err: ContractError = test .create( - coin(100, "ujuno"), + coin(bounty_amount, JUNO_DENOM), "title".to_string(), Some("description".to_string()), - &[coin(50, "ujuno")], + &[coin(bounty_amount / 2, JUNO_DENOM)], ) .unwrap_err() .downcast() @@ -183,18 +211,18 @@ pub fn test_create_bounty() { assert_eq!( err, ContractError::InvalidAmount { - expected: Uint128::new(100), - actual: Uint128::new(50) + expected: Uint128::new(bounty_amount), + actual: Uint128::new(bounty_amount / 2) } ); // create bounty with bigger amount let err: ContractError = test .create( - coin(100, "ujuno"), + coin(bounty_amount, JUNO_DENOM), "title".to_string(), Some("description".to_string()), - &[coin(150, "ujuno")], + &[coin(2 * bounty_amount, JUNO_DENOM)], ) .unwrap_err() .downcast() @@ -202,8 +230,8 @@ pub fn test_create_bounty() { assert_eq!( err, ContractError::InvalidAmount { - expected: Uint128::new(100), - actual: Uint128::new(150) + expected: Uint128::new(bounty_amount), + actual: Uint128::new(2 * bounty_amount) } ); } @@ -211,18 +239,27 @@ pub fn test_create_bounty() { #[test] pub fn test_close_bounty() { let mut test = Test::new(); + let bounty_amount = 100; // create bounty test.create( - coin(100, "ujuno"), + coin(bounty_amount, JUNO_DENOM), "title".to_string(), Some("description".to_string()), - &[coin(100, "ujuno")], + &[coin(bounty_amount, JUNO_DENOM)], ) .unwrap(); // close bounty test.close(1).unwrap(); + // - assert bounty status + let bounty = test.query(1).unwrap(); + assert_eq!( + bounty.status, + BountyStatus::Closed { + closed_at: test.app.block_info().time.seconds(), + } + ); // close bounty again let err: ContractError = test.close(1).unwrap_err().downcast().unwrap(); @@ -231,195 +268,428 @@ pub fn test_close_bounty() { #[test] pub fn test_update_bounty() { - let mut test = Test::new(); - - let initial_juno_balance = test - .app - .wrap() - .query_balance(test.owner.clone(), "ujuno") + let bounty_amount = 100; + // case: update bounty with higher amount + { + let mut test = Test::new(); + let initial_juno_balance = test + .app + .wrap() + .query_balance(test.owner.clone(), JUNO_DENOM) + .unwrap(); + // create bounty + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) .unwrap(); - let initial_atom_balance = test - .app - .wrap() - .query_balance(test.owner.clone(), "uatom") + + test.update( + 1, + coin(2 * bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) .unwrap(); - // create bounty - test.create( - coin(100, "ujuno"), - "title".to_string(), - Some("description".to_string()), - &[coin(100, "ujuno")], - ) - .unwrap(); + // - assert bounty + let bounty = test.query(1).unwrap(); + assert_eq!( + bounty, + Bounty { + id: 1, + amount: coin(2 * bounty_amount, JUNO_DENOM), + title: "title".to_string(), + description: Some("description".to_string()), + status: BountyStatus::Open, + created_at: test.app.block_info().time.seconds(), + updated_at: Some(test.app.block_info().time.seconds()), + } + ); + // assert balance + let balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), JUNO_DENOM) + .unwrap(); + assert!( + initial_juno_balance.amount.u128() == (balance_after.amount.u128() + 2 * bounty_amount), + "before: {}, after: {}", + initial_juno_balance.amount.u128(), + balance_after.amount.u128() + ); + } - // update bounty - test.update( - 1, - coin(200, "ujuno"), - "title".to_string(), - Some("description".to_string()), - &[coin(100, "ujuno")], - ) - .unwrap(); - // assert balance - let balance_after = test - .app - .wrap() - .query_balance(test.owner.clone(), "ujuno") + // case: update bounty with lower amount + { + let mut test = Test::new(); + let initial_juno_balance = test + .app + .wrap() + .query_balance(test.owner.clone(), JUNO_DENOM) + .unwrap(); + // create bounty + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) .unwrap(); - assert!( - initial_juno_balance.amount.u128() == (balance_after.amount.u128() + 200), - "before: {}, after: {}", - initial_juno_balance.amount.u128(), - balance_after.amount.u128() - ); - // update bounty without sending funds - let err: ContractError = test - .update( + test.update( 1, - coin(300, "ujuno"), + coin(bounty_amount / 2, JUNO_DENOM), "title".to_string(), Some("description".to_string()), - &[], + &[], // no funds needed, since update amount is lower ) - .unwrap_err() - .downcast() .unwrap(); - assert_eq!(err, ContractError::PaymentError(PaymentError::NoFunds {})); + // - assert bounty + let bounty = test.query(1).unwrap(); + assert_eq!( + bounty, + Bounty { + id: 1, + amount: coin(bounty_amount / 2, JUNO_DENOM), + title: "title".to_string(), + description: Some("description".to_string()), + status: BountyStatus::Open, + created_at: test.app.block_info().time.seconds(), + updated_at: Some(test.app.block_info().time.seconds()), + } + ); + // assert balance + let balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), JUNO_DENOM) + .unwrap(); + assert!( + initial_juno_balance.amount.u128() == (balance_after.amount.u128() + bounty_amount / 2), + "before: {}, after: {}", + initial_juno_balance.amount.u128(), + balance_after.amount.u128() + ); + } - // update bounty with lower amount - let err: ContractError = test - .update( - 1, - coin(300, "ujuno"), + // case: update bounty with lower amount + owner accidentally send funds + { + let mut test = Test::new(); + let initial_juno_balance = test + .app + .wrap() + .query_balance(test.owner.clone(), JUNO_DENOM) + .unwrap(); + // create bounty + test.create( + coin(bounty_amount, JUNO_DENOM), "title".to_string(), Some("description".to_string()), - &[coin(50, "ujuno")], + &[coin(bounty_amount, JUNO_DENOM)], ) - .unwrap_err() - .downcast() .unwrap(); - assert_eq!( - err, - ContractError::InvalidAmount { - expected: Uint128::new(100), - actual: Uint128::new(250) - } - ); - // update bounty with bigger amount - let err: ContractError = test - .update( + test.update( 1, - coin(300, "ujuno"), + coin(bounty_amount / 2, JUNO_DENOM), "title".to_string(), Some("description".to_string()), - &[coin(150, "ujuno")], + &[coin(bounty_amount, JUNO_DENOM)], ) - .unwrap_err() - .downcast() .unwrap(); - assert_eq!( - err, - ContractError::InvalidAmount { - expected: Uint128::new(100), - actual: Uint128::new(350) - } - ); + // - assert bounty + let bounty = test.query(1).unwrap(); + assert_eq!( + bounty, + Bounty { + id: 1, + amount: coin(bounty_amount / 2, JUNO_DENOM), + title: "title".to_string(), + description: Some("description".to_string()), + status: BountyStatus::Open, + created_at: test.app.block_info().time.seconds(), + updated_at: Some(test.app.block_info().time.seconds()), + } + ); + // assert balance + let balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), JUNO_DENOM) + .unwrap(); + assert!( + initial_juno_balance.amount.u128() == (balance_after.amount.u128() + bounty_amount / 2), + "before: {}, after: {}", + initial_juno_balance.amount.u128(), + balance_after.amount.u128() + ); + } - // update bounty with different denom - test.update( - 1, - coin(200, "uatom"), - "title".to_string(), - Some("description".to_string()), - &[coin(200, "uatom")], - ) - .unwrap(); - // assert juno balance - let juno_balance_after = test - .app - .wrap() - .query_balance(test.owner.clone(), "ujuno") + // case: update bounty sending incorrect funds + { + let mut test = Test::new(); + // create bounty + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) .unwrap(); - assert!( - juno_balance_after.amount == initial_juno_balance.amount, - "before: {}, after: {}", - initial_juno_balance.amount.u128(), - balance_after.amount.u128() - ); - let atom_balance_after = test - .app - .wrap() - .query_balance(test.owner.clone(), "uatom") + let err: ContractError = test + .update( + 1, + coin(3 * bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::PaymentError(PaymentError::NoFunds {})); + + // update bounty with lower amount + let err: ContractError = test + .update( + 1, + coin(2 * bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount / 2, JUNO_DENOM)], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::InvalidAmount { + expected: Uint128::new(bounty_amount), + actual: Uint128::new(bounty_amount + bounty_amount / 2) + } + ); + + // update bounty with bigger amount + let err: ContractError = test + .update( + 1, + coin(2 * bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(2 * bounty_amount, JUNO_DENOM)], // 100 already in bounty, now sending 200 + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::InvalidAmount { + expected: Uint128::new(bounty_amount), + actual: Uint128::new(3 * bounty_amount) + } + ); + } + + // case: update bounty with different denom + { + let mut test = Test::new(); + let initial_juno_balance = test + .app + .wrap() + .query_balance(test.owner.clone(), JUNO_DENOM) + .unwrap(); + let initial_atom_balance = test + .app + .wrap() + .query_balance(test.owner.clone(), ATOM_DENOM) + .unwrap(); + // create bounty + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) .unwrap(); - assert!( - atom_balance_after.amount.u128() == (initial_atom_balance.amount.u128() - 200), - "before: {}, after: {}", - initial_atom_balance.amount.u128(), - atom_balance_after.amount.u128() - ); - // test closed bounty - test.close(1).unwrap(); - let err: ContractError = test - .update( + test.update( 1, - coin(200, "ujuno"), + coin(200, ATOM_DENOM), "title".to_string(), Some("description".to_string()), - &[coin(200, "ujuno")], + &[coin(200, ATOM_DENOM)], ) - .unwrap_err() - .downcast() .unwrap(); - assert_eq!(err, ContractError::NotOpen {}); + // - assert bounty + let bounty = test.query(1).unwrap(); + assert_eq!( + bounty, + Bounty { + id: 1, + amount: coin(2 * bounty_amount, ATOM_DENOM), + title: "title".to_string(), + description: Some("description".to_string()), + status: BountyStatus::Open, + created_at: test.app.block_info().time.seconds(), + updated_at: Some(test.app.block_info().time.seconds()), + } + ); + // assert juno balance + let juno_balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), JUNO_DENOM) + .unwrap(); + assert!( + juno_balance_after.amount == initial_juno_balance.amount, + "before: {}, after: {}", + initial_juno_balance.amount.u128(), + juno_balance_after.amount.u128() + ); + // assert atom balance + let atom_balance_after = test + .app + .wrap() + .query_balance(test.owner.clone(), ATOM_DENOM) + .unwrap(); + assert!( + atom_balance_after.amount.u128() == (initial_atom_balance.amount.u128() - 200), + "before: {}, after: {}", + initial_atom_balance.amount.u128(), + atom_balance_after.amount.u128() + ); + } + + // case: update bounty with different denom, but owner sends with incorrect denom + { + let mut test = Test::new(); + // create bounty + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) + .unwrap(); + + let err: ContractError = test + .update( + 1, + coin(200, ATOM_DENOM), // update with atom denom + "title".to_string(), + Some("description".to_string()), + &[coin(200, JUNO_DENOM)], // but send juno denom + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::PaymentError(PaymentError::MissingDenom(ATOM_DENOM.to_string())) + ); + } + + // case: update on closed bounty + { + let mut test = Test::new(); + // create bounty + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) + .unwrap(); + + test.close(1).unwrap(); + let err: ContractError = test + .update( + 1, + coin(2 * bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(2 * bounty_amount, JUNO_DENOM)], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::NotOpen {}); + } } #[test] pub fn test_pay_out_bounty() { - let mut test = Test::new(); + let bounty_amount = 100; + // case: payout + { + let mut test = Test::new(); + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) + .unwrap(); - // create bounty - test.create( - coin(100, "ujuno"), - "title".to_string(), - Some("description".to_string()), - &[coin(100, "ujuno")], - ) - .unwrap(); + let initial_juno_balance = test + .app + .wrap() + .query_balance(test.recipient.clone(), JUNO_DENOM) + .unwrap(); - let initial_juno_balance = test - .app - .wrap() - .query_balance(test.recipient.clone(), "ujuno") - .unwrap(); - // pay out bounty - test.pay_out(1).unwrap(); - // assert balance - let balance_after = test - .app - .wrap() - .query_balance(test.recipient.clone(), "ujuno") + test.pay_out(1).unwrap(); + // assert balance + let balance_after = test + .app + .wrap() + .query_balance(test.recipient.clone(), JUNO_DENOM) + .unwrap(); + assert!( + initial_juno_balance.amount.u128() + bounty_amount == (balance_after.amount.u128()), + "before: {}, after: {}", + initial_juno_balance.amount.u128(), + balance_after.amount.u128() + ); + // assert bounty + let bounty = test.query(1).unwrap(); + assert_eq!( + bounty, + Bounty { + id: 1, + amount: coin(bounty_amount, JUNO_DENOM), + title: "title".to_string(), + description: Some("description".to_string()), + status: BountyStatus::Claimed { + claimed_by: test.recipient.to_string(), + claimed_at: test.app.block_info().time.seconds() + }, + created_at: test.app.block_info().time.seconds(), + updated_at: None, + } + ); + + // - test bounty already claimed + let err: ContractError = test.pay_out(1).unwrap_err().downcast().unwrap(); + assert_eq!(err, ContractError::NotOpen {}); + } + + // case: payout of closed bounty, this covered above, but just to be sure and test on manual close + { + let mut test = Test::new(); + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) .unwrap(); - assert!( - initial_juno_balance.amount.u128() + 100 == (balance_after.amount.u128()), - "before: {}, after: {}", - initial_juno_balance.amount.u128(), - balance_after.amount.u128() - ); - // assert bounty claimed - let bounty = test.query(1).unwrap(); - assert_eq!( - bounty.status, - BountyStatus::Claimed { - claimed_by: test.recipient.to_string(), - claimed_at: test.app.block_info().time.seconds() - } - ); + test.close(1).unwrap(); - // test bounty already claimed - let err: ContractError = test.pay_out(1).unwrap_err().downcast().unwrap(); - assert_eq!(err, ContractError::NotOpen {}); + // - test bounty already claimed + let err: ContractError = test.pay_out(1).unwrap_err().downcast().unwrap(); + assert_eq!(err, ContractError::NotOpen {}); + } } From 23d6c762e785a62aab3fccac6bbe529e4e790137 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Thu, 9 Nov 2023 15:21:30 +0100 Subject: [PATCH 07/13] Update schema --- contracts/external/cw-bounties/schema/cw-bounties.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/external/cw-bounties/schema/cw-bounties.json b/contracts/external/cw-bounties/schema/cw-bounties.json index c25fd5419..f7f3179f2 100644 --- a/contracts/external/cw-bounties/schema/cw-bounties.json +++ b/contracts/external/cw-bounties/schema/cw-bounties.json @@ -1,6 +1,6 @@ { "contract_name": "cw-bounties", - "contract_version": "2.1.0", + "contract_version": "2.3.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", From ad7d1803dfcc7b7fe6d701e6e58f430f63433cda Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Fri, 29 Dec 2023 02:57:03 -0800 Subject: [PATCH 08/13] Fix path Co-authored-by: bekauz --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d83585909..76f472a38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,7 +679,7 @@ dependencies = [ [[package]] name = "cw-bounties" -version = "2.3.0" +version = "2.4.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -687,10 +687,10 @@ dependencies = [ "cw-denom", "cw-multi-test", "cw-ownable", - "cw-paginate-storage 2.3.0", - "cw-storage-plus 1.1.0", - "cw-utils 1.0.1", - "cw2 1.1.0", + "cw-paginate-storage 2.4.0", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", "thiserror", ] From 73cb89e3801e5cfb4b4bab76e047621fd2f8a5fd Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Fri, 29 Dec 2023 14:41:24 -0800 Subject: [PATCH 09/13] to_binary -> to_json_binary --- contracts/external/cw-bounties/src/contract.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/contracts/external/cw-bounties/src/contract.rs b/contracts/external/cw-bounties/src/contract.rs index 181e65839..c31c17144 100644 --- a/contracts/external/cw-bounties/src/contract.rs +++ b/contracts/external/cw-bounties/src/contract.rs @@ -3,7 +3,8 @@ use std::cmp::Ordering; #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - to_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, Response, StdResult, + to_json_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Order, Response, + StdResult, }; use cw2::set_contract_version; use cw_paginate_storage::paginate_map_values; @@ -292,15 +293,15 @@ pub fn update_owner( #[cfg_attr(not(feature = "library"), entry_point)] pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { match msg { - QueryMsg::Bounty { id } => to_binary(&BOUNTIES.load(deps.storage, id)?), - QueryMsg::Bounties { start_after, limit } => to_binary(&paginate_map_values( + QueryMsg::Bounty { id } => to_json_binary(&BOUNTIES.load(deps.storage, id)?), + QueryMsg::Bounties { start_after, limit } => to_json_binary(&paginate_map_values( deps, &BOUNTIES, start_after, limit, Order::Descending, )?), - QueryMsg::Count {} => to_binary(&ID.load(deps.storage)?), - QueryMsg::Ownership {} => to_binary(&cw_ownable::get_ownership(deps.storage)?), + QueryMsg::Count {} => to_json_binary(&ID.load(deps.storage)?), + QueryMsg::Ownership {} => to_json_binary(&cw_ownable::get_ownership(deps.storage)?), } } From 983dba83e1b0dca9b7799a4fadef8b9f7c7bee0d Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Tue, 2 Jan 2024 20:23:30 -0800 Subject: [PATCH 10/13] Add more info to readme, including disclaimer --- contracts/external/cw-bounties/README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/contracts/external/cw-bounties/README.md b/contracts/external/cw-bounties/README.md index 2b17e5d47..fc8986e37 100644 --- a/contracts/external/cw-bounties/README.md +++ b/contracts/external/cw-bounties/README.md @@ -2,6 +2,18 @@ A simple bounties smart contract. The contract is instantiated with an owner who controls when bounties are payed out (usually a DAO). -On bounty creation the funds are taken, on update funds are added or removed and bounty details can be updated, on removal funds are returned to the bounties contract owner. +NOTE: this contract has NOT BEEN AUDITED and is not recommended for production use. Use at your own risk. -Typical usage would involve a SubDAO with open proposal submission. Bounty hunters would be able to see a list of bounties, work on one and make a proposal to claim it. +## Overview + +On `create` the bounty funds sent along with the transaction are taken and held in escrow. + +On `update` funds are added or removed and bounty details can be updated. If the updated amount is less than the original amount, or if the `denom` for the payout has changed (for example, switching from $USDC to $JUNO), funds will be returned to the contract owner (again, usually a DAO). + +On `close` funds are returned to the bounties contract owner. + +Typical usage would involve a DAO DAO SubDAO with open proposal submission. Bounty hunters would be able to see a list of bounties, work on one and make a proposal to claim it. + +## Future work +- [ ] Support partial claims (i.e. I did some meaninful work but didn't finish the bounty, so claiming only part of it). +- [ ] Support bounties with multiple claims (i.e. a task with the first three people to complete it pays out an equal reward to all). From 0d831d0c7919128fecd5d5ff6710ae33f565e18b Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Tue, 2 Jan 2024 20:23:58 -0800 Subject: [PATCH 11/13] Clean up comments --- contracts/external/cw-bounties/schema/cw-bounties.json | 4 ++-- contracts/external/cw-bounties/src/contract.rs | 6 +++--- contracts/external/cw-bounties/src/msg.rs | 1 - contracts/external/cw-bounties/src/state.rs | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/contracts/external/cw-bounties/schema/cw-bounties.json b/contracts/external/cw-bounties/schema/cw-bounties.json index f7f3179f2..9b6d695f9 100644 --- a/contracts/external/cw-bounties/schema/cw-bounties.json +++ b/contracts/external/cw-bounties/schema/cw-bounties.json @@ -1,6 +1,6 @@ { "contract_name": "cw-bounties", - "contract_version": "2.3.0", + "contract_version": "2.4.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -116,7 +116,7 @@ "additionalProperties": false }, { - "description": "TODO make vesting payout? Updates a bounty (only owner)", + "description": "Updates a bounty (only owner)", "type": "object", "required": [ "update" diff --git a/contracts/external/cw-bounties/src/contract.rs b/contracts/external/cw-bounties/src/contract.rs index c31c17144..f493c02ba 100644 --- a/contracts/external/cw-bounties/src/contract.rs +++ b/contracts/external/cw-bounties/src/contract.rs @@ -89,7 +89,7 @@ pub fn create( return Err(ContractError::EmptyTitle {}); } - // Increment and get the next bounty ID + // Increment and get the next bounty ID, the first bounty will have ID 1 let id = ID.update(deps.storage, |mut id| -> StdResult { id += 1; Ok(id) @@ -221,7 +221,7 @@ pub fn update( .add_attribute("bounty_id", id.to_string()) .add_attribute("amount", new_amount.amount.to_string()); - // check if new amount has different denom + // Check if new amount has different denom if new_amount.denom != bounty.amount.denom { // If denom is different, check funds sent match new amount let sent_amount = must_pay(&info, &new_amount.denom)?; @@ -231,7 +231,7 @@ pub fn update( actual: sent_amount, }); } - // send back old amount + // Send back old amount let msg = BankMsg::Send { to_address: info.sender.to_string(), amount: vec![Coin { diff --git a/contracts/external/cw-bounties/src/msg.rs b/contracts/external/cw-bounties/src/msg.rs index 01e30836d..747c11cee 100644 --- a/contracts/external/cw-bounties/src/msg.rs +++ b/contracts/external/cw-bounties/src/msg.rs @@ -33,7 +33,6 @@ pub enum ExecuteMsg { /// Recipient address where funds from bounty are claimed recipient: String, }, - /// TODO make vesting payout? /// Updates a bounty (only owner) Update { /// The ID of the bounty diff --git a/contracts/external/cw-bounties/src/state.rs b/contracts/external/cw-bounties/src/state.rs index df683fc30..b2401f7c0 100644 --- a/contracts/external/cw-bounties/src/state.rs +++ b/contracts/external/cw-bounties/src/state.rs @@ -2,7 +2,6 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::Coin; use cw_storage_plus::{Item, Map}; -// TODO add timestamps // A struct representing a bounty #[cw_serde] pub struct Bounty { From 12adcb440de4b3191be3b2e6d47116d90eb29d12 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Mon, 8 Jan 2024 16:27:35 -0800 Subject: [PATCH 12/13] new_amount -> new_coin --- .../external/cw-bounties/src/contract.rs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/contracts/external/cw-bounties/src/contract.rs b/contracts/external/cw-bounties/src/contract.rs index f493c02ba..4cba068e3 100644 --- a/contracts/external/cw-bounties/src/contract.rs +++ b/contracts/external/cw-bounties/src/contract.rs @@ -189,7 +189,7 @@ pub fn update( env: Env, info: MessageInfo, id: u64, - new_amount: Coin, + new_coin: Coin, title: String, description: Option, ) -> Result { @@ -207,7 +207,7 @@ pub fn update( bounty.id, &Bounty { id: bounty.id, - amount: new_amount.clone(), + amount: new_coin.clone(), title, description, status: bounty.status, @@ -219,15 +219,15 @@ pub fn update( let res = Response::new() .add_attribute("action", "update_bounty") .add_attribute("bounty_id", id.to_string()) - .add_attribute("amount", new_amount.amount.to_string()); + .add_attribute("amount", new_coin.amount.to_string()); // Check if new amount has different denom - if new_amount.denom != bounty.amount.denom { + if new_coin.denom != bounty.amount.denom { // If denom is different, check funds sent match new amount - let sent_amount = must_pay(&info, &new_amount.denom)?; - if sent_amount != new_amount.amount { + let sent_amount = must_pay(&info, &new_coin.denom)?; + if sent_amount != new_coin.amount { return Err(ContractError::InvalidAmount { - expected: new_amount.amount, + expected: new_coin.amount, actual: sent_amount, }); } @@ -245,14 +245,14 @@ pub fn update( // Check if amount is greater or less than original amount let old_amount = bounty.amount.clone(); - match new_amount.amount.cmp(&old_amount.amount) { + match new_coin.amount.cmp(&old_amount.amount) { Ordering::Greater => { // If new amount is greater, check funds sent plus // original amount match new amount - let sent_amount = must_pay(&info, &new_amount.denom)?; - if sent_amount + old_amount.amount != new_amount.amount { + let sent_amount = must_pay(&info, &new_coin.denom)?; + if sent_amount + old_amount.amount != new_coin.amount { return Err(ContractError::InvalidAmount { - expected: new_amount.amount - old_amount.amount, + expected: new_coin.amount - old_amount.amount, actual: sent_amount + old_amount.amount, }); } @@ -262,7 +262,7 @@ pub fn update( // If new amount is less, pay out difference to owner // in case owner accidentally sent funds, send back as well let funds_send = may_pay(&info, &bounty.amount.denom)?; - let diff = old_amount.amount - new_amount.amount + funds_send; + let diff = old_amount.amount - new_coin.amount + funds_send; let msg = BankMsg::Send { to_address: info.sender.to_string(), amount: vec![Coin { From 793085a82117c65011ab9fba0883c9cb641593d1 Mon Sep 17 00:00:00 2001 From: Jake Hartnell Date: Tue, 26 Mar 2024 14:43:55 +0100 Subject: [PATCH 13/13] Post rebase fixups --- Cargo.lock | 4 ++-- contracts/external/cw-bounties/schema/cw-bounties.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76f472a38..654a3d463 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,7 +679,7 @@ dependencies = [ [[package]] name = "cw-bounties" -version = "2.4.0" +version = "2.4.1" dependencies = [ "anyhow", "cosmwasm-schema", @@ -687,7 +687,7 @@ dependencies = [ "cw-denom", "cw-multi-test", "cw-ownable", - "cw-paginate-storage 2.4.0", + "cw-paginate-storage 2.4.1", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw2 1.1.2", diff --git a/contracts/external/cw-bounties/schema/cw-bounties.json b/contracts/external/cw-bounties/schema/cw-bounties.json index 9b6d695f9..d59ebb448 100644 --- a/contracts/external/cw-bounties/schema/cw-bounties.json +++ b/contracts/external/cw-bounties/schema/cw-bounties.json @@ -1,6 +1,6 @@ { "contract_name": "cw-bounties", - "contract_version": "2.4.0", + "contract_version": "2.4.1", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#",