diff --git a/Cargo.lock b/Cargo.lock index 6128b4f1c..654a3d463 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -677,6 +677,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw-bounties" +version = "2.4.1" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-denom", + "cw-multi-test", + "cw-ownable", + "cw-paginate-storage 2.4.1", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "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..dca456e4b --- /dev/null +++ b/contracts/external/cw-bounties/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "cw-bounties" +authors = ["Jake Hartnell", "Mr T "] +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 } +anyhow = { workspace = true } diff --git a/contracts/external/cw-bounties/README.md b/contracts/external/cw-bounties/README.md new file mode 100644 index 000000000..fc8986e37 --- /dev/null +++ b/contracts/external/cw-bounties/README.md @@ -0,0 +1,19 @@ +# cw-bounties + +A simple bounties smart contract. The contract is instantiated with an owner who controls when bounties are payed out (usually a DAO). + +NOTE: this contract has NOT BEEN AUDITED and is not recommended for production use. Use at your own risk. + +## 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). 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/schema/cw-bounties.json b/contracts/external/cw-bounties/schema/cw-bounties.json new file mode 100644 index 000000000..d59ebb448 --- /dev/null +++ b/contracts/external/cw-bounties/schema/cw-bounties.json @@ -0,0 +1,827 @@ +{ + "contract_name": "cw-bounties", + "contract_version": "2.4.1", + "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": "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" + } + } + } + } +} diff --git a/contracts/external/cw-bounties/src/contract.rs b/contracts/external/cw-bounties/src/contract.rs new file mode 100644 index 000000000..4cba068e3 --- /dev/null +++ b/contracts/external/cw-bounties/src/contract.rs @@ -0,0 +1,307 @@ +use std::cmp::Ordering; + +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + 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; +use cw_utils::{may_pay, 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, &0)?; + + 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, env, 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, the first bounty will have ID 1 + 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, + env: Env, + info: MessageInfo, + id: u64, +) -> 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 {}); + }; + + 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 { + 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_coin: 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_coin.clone(), + title, + description, + status: bounty.status, + created_at: bounty.created_at, + updated_at: Some(env.block.time.seconds()), + }, + )?; + + let res = Response::new() + .add_attribute("action", "update_bounty") + .add_attribute("bounty_id", id.to_string()) + .add_attribute("amount", new_coin.amount.to_string()); + + // Check if new amount has different 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_coin.denom)?; + if sent_amount != new_coin.amount { + return Err(ContractError::InvalidAmount { + expected: new_coin.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.clone(); + 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_coin.denom)?; + if sent_amount + old_amount.amount != new_coin.amount { + return Err(ContractError::InvalidAmount { + expected: new_coin.amount - old_amount.amount, + actual: sent_amount + old_amount.amount, + }); + } + Ok(res) + } + Ordering::Less => { + // 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_coin.amount + funds_send; + 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_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_json_binary(&ID.load(deps.storage)?), + QueryMsg::Ownership {} => to_json_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..b2401f7c0 --- /dev/null +++ b/contracts/external/cw-bounties/src/state.rs @@ -0,0 +1,36 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Coin; +use cw_storage_plus::{Item, Map}; + +// 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..111ffd523 --- /dev/null +++ b/contracts/external/cw-bounties/src/tests.rs @@ -0,0 +1,695 @@ +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::{Bounty, BountyStatus}, + ContractError, +}; + +pub struct Test { + pub app: App, + 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"); + let recipient = Addr::unchecked("recipient"); + let mut app = App::new(|router, _, storage| { + router + .bank + .init_balance( + storage, + &owner, + vec![coin(10000, JUNO_DENOM), coin(10000, ATOM_DENOM)], + ) + .unwrap(); + router + .bank + .init_balance( + storage, + &recipient, + vec![coin(10000, JUNO_DENOM), coin(10000, ATOM_DENOM)], + ) + .unwrap(); + }); + let code_id = app.store_code(bounty_countract()); + let contract = app + .instantiate_contract( + code_id, + owner.clone(), + &InstantiateMsg { + owner: owner.to_string(), + }, + &[], + "cw-bounties", + None, + ) + .unwrap(); + Self { + app, + contract, + 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.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.contract.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.contract.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.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.contract, &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 bounty_amount = 100; + let balance_before = 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 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(), JUNO_DENOM) + .unwrap(); + assert!( + balance_before.amount.u128() == (balance_after.amount.u128() + bounty_amount), + "before: {}, after: {}", + balance_before.amount.u128(), + balance_after.amount.u128() + ); + + // create bounty without sending funds + let err: ContractError = test + .create( + coin(bounty_amount, JUNO_DENOM), + "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(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 / 2) + } + ); + + // create bounty with bigger amount + let err: ContractError = test + .create( + coin(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::InvalidAmount { + expected: Uint128::new(bounty_amount), + actual: Uint128::new(2 * bounty_amount) + } + ); +} + +#[test] +pub fn test_close_bounty() { + let mut test = Test::new(); + let bounty_amount = 100; + + // create bounty + test.create( + coin(bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[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(); + assert_eq!(err, ContractError::NotOpen {}); +} + +#[test] +pub fn test_update_bounty() { + 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(); + + test.update( + 1, + coin(2 * bounty_amount, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) + .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() + ); + } + + // 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(); + + test.update( + 1, + coin(bounty_amount / 2, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[], // no funds needed, since update amount is lower + ) + .unwrap(); + // - 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() + ); + } + + // 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(bounty_amount, JUNO_DENOM)], + ) + .unwrap(); + + test.update( + 1, + coin(bounty_amount / 2, JUNO_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(bounty_amount, JUNO_DENOM)], + ) + .unwrap(); + // - 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() + ); + } + + // 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(); + 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(); + + test.update( + 1, + coin(200, ATOM_DENOM), + "title".to_string(), + Some("description".to_string()), + &[coin(200, ATOM_DENOM)], + ) + .unwrap(); + // - 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 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(); + + let initial_juno_balance = test + .app + .wrap() + .query_balance(test.recipient.clone(), JUNO_DENOM) + .unwrap(); + + 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(); + test.close(1).unwrap(); + + // - test bounty already claimed + let err: ContractError = test.pay_out(1).unwrap_err().downcast().unwrap(); + assert_eq!(err, ContractError::NotOpen {}); + } +}