diff --git a/Cargo.lock b/Cargo.lock index 840d82ea3..e2f3bd7c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,6 +563,22 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw-bounties" +version = "2.2.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-multi-test", + "cw-ownable", + "cw-paginate-storage 2.2.0", + "cw-storage-plus 1.1.0", + "cw-utils 0.16.0", + "cw2 0.16.0", + "thiserror", +] + [[package]] name = "cw-controllers" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index c18be7f77..b67a732e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ wynd-utils = "0.4.1" cw-ownable = "0.5.0" cw-admin-factory = { path = "./contracts/external/cw-admin-factory", version = "2.2.0" } +cw-bounties = { path = "contracts/external/cw-bounties", version = "*" } cw-denom = { path = "./packages/cw-denom", version = "2.2.0" } cw-hooks = { path = "./packages/cw-hooks", version = "2.2.0" } cw-wormhole = { path = "./packages/cw-wormhole", version = "2.2.0" } 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