diff --git a/contracts/distribution/.cargo/config b/contracts/distribution/.cargo/config new file mode 100644 index 00000000..7c115322 --- /dev/null +++ b/contracts/distribution/.cargo/config @@ -0,0 +1,6 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib --features backtraces" +integration-test = "test --test integration" +schema = "run --example schema" diff --git a/contracts/distribution/Cargo.toml b/contracts/distribution/Cargo.toml new file mode 100644 index 00000000..3684a775 --- /dev/null +++ b/contracts/distribution/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "neutron-distribution" +version = "0.1.0" +authors = ["Sergey Ratiashvili "] +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/neutron/neutron-dao" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] + +[dependencies] +cosmwasm-std = { version = "1.0" } +cw-storage-plus = "1.0.1" +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } + +[dev-dependencies] +cosmwasm-schema = { version = "1.0.0", default-features = false } diff --git a/contracts/distribution/Makefile b/contracts/distribution/Makefile new file mode 100644 index 00000000..e2aa6a26 --- /dev/null +++ b/contracts/distribution/Makefile @@ -0,0 +1,9 @@ +CURRENT_DIR = $(shell pwd) +CURRENT_DIR_RELATIVE = $(notdir $(shell pwd)) + +clippy: + rustup component add clippy || true + cargo clippy --all-targets --all-features --workspace -- -D warnings + +test: clippy + cargo unit-test diff --git a/contracts/distribution/README.md b/contracts/distribution/README.md new file mode 100644 index 00000000..9db39578 --- /dev/null +++ b/contracts/distribution/README.md @@ -0,0 +1 @@ +# Neutron treasury diff --git a/contracts/distribution/examples/schema.rs b/contracts/distribution/examples/schema.rs new file mode 100644 index 00000000..81843cbb --- /dev/null +++ b/contracts/distribution/examples/schema.rs @@ -0,0 +1,31 @@ +// Copyright 2022 Neutron +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; + +use neutron_distribution::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); +} diff --git a/contracts/distribution/schema/execute_msg.json b/contracts/distribution/schema/execute_msg.json new file mode 100644 index 00000000..b2191627 --- /dev/null +++ b/contracts/distribution/schema/execute_msg.json @@ -0,0 +1,84 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Transfer the contract's ownership to another account", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "set_shares" + ], + "properties": { + "set_shares": { + "type": "object", + "required": [ + "shares" + ], + "properties": { + "shares": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/Uint128" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Distribute funds between share holders. It is called from treasury contract only when part of the fund is going to distribution betrween share holders.", + "type": "object", + "required": [ + "fund" + ], + "properties": { + "fund": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "description": "Claim the funds that have been distributed to the contract's account", + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "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" + } + } +} diff --git a/contracts/distribution/schema/instantiate_msg.json b/contracts/distribution/schema/instantiate_msg.json new file mode 100644 index 00000000..92d4425c --- /dev/null +++ b/contracts/distribution/schema/instantiate_msg.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "denom", + "owner" + ], + "properties": { + "denom": { + "type": "string" + }, + "owner": { + "description": "The contract's owner", + "type": "string" + } + } +} diff --git a/contracts/distribution/schema/query_msg.json b/contracts/distribution/schema/query_msg.json new file mode 100644 index 00000000..ded8cb2f --- /dev/null +++ b/contracts/distribution/schema/query_msg.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "The contract's configurations; returns [`ConfigResponse`]", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "pending" + ], + "properties": { + "pending": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "shares" + ], + "properties": { + "shares": { + "type": "object" + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/distribution/src/contract.rs b/contracts/distribution/src/contract.rs new file mode 100644 index 00000000..ca70e588 --- /dev/null +++ b/contracts/distribution/src/contract.rs @@ -0,0 +1,222 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Addr, BankMsg, Binary, Coin, CosmosMsg, Deps, DepsMut, Env, MessageInfo, Order, + Response, StdError, StdResult, Storage, Uint128, +}; + +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{Config, CONFIG, FUND_COUNTER, PENDING_DISTRIBUTION, SHARES}; + +//-------------------------------------------------------------------------------------------------- +// Instantiation +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + let config = Config { + denom: msg.denom, + owner: deps.api.addr_validate(&msg.owner)?, + }; + CONFIG.save(deps.storage, &config)?; + Ok(Response::new()) +} + +//-------------------------------------------------------------------------------------------------- +// Executions +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + _env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> StdResult { + let api = deps.api; + match msg { + // permissioned - owner + ExecuteMsg::TransferOwnership(new_owner) => { + execute_transfer_ownership(deps, info, api.addr_validate(&new_owner)?) + } + + // permissioned - owner + ExecuteMsg::SetShares { shares } => execute_set_shares(deps, info, shares), + + // permissionless + ExecuteMsg::Fund {} => execute_fund(deps, info), + + // permissioned - owner of the share + ExecuteMsg::Claim {} => execute_claim(deps, info), + } +} + +pub fn execute_transfer_ownership( + deps: DepsMut, + info: MessageInfo, + new_owner_addr: Addr, +) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let old_owner = config.owner; + let sender_addr = info.sender; + if sender_addr != old_owner { + return Err(StdError::generic_err("unauthorized")); + } + + CONFIG.update(deps.storage, |mut config| -> StdResult<_> { + config.owner = new_owner_addr.clone(); + Ok(config) + })?; + + Ok(Response::new() + .add_attribute("action", "neutron/distribution/transfer_ownership") + .add_attribute("previous_owner", old_owner) + .add_attribute("new_owner", new_owner_addr)) +} + +fn get_denom_amount(coins: Vec, denom: String) -> Option { + coins + .into_iter() + .find(|c| c.denom == denom) + .map(|c| c.amount) +} + +pub fn execute_fund(deps: DepsMut, info: MessageInfo) -> StdResult { + let config: Config = CONFIG.load(deps.storage)?; + let denom = config.denom; + let fund_counter = FUND_COUNTER.may_load(deps.storage)?.unwrap_or(0); + let funds = get_denom_amount(info.funds, denom).unwrap_or(Uint128::zero()); + if funds.is_zero() { + return Err(StdError::generic_err("no funds sent")); + } + let shares = SHARES + .range(deps.storage, None, None, Order::Ascending) + .into_iter() + .collect::>>()?; + if shares.is_empty() { + return Err(StdError::generic_err("no shares set")); + } + let total_shares = shares.iter().fold(Uint128::zero(), |acc, (_, s)| acc + s); + let mut spent = Uint128::zero(); + let mut resp = Response::new().add_attribute("action", "neutron/distribution/fund"); + + for (addr, share) in shares.iter() { + let amount = funds.checked_mul(*share)?.checked_div(total_shares)?; + let pending = PENDING_DISTRIBUTION + .may_load(deps.storage, addr.clone())? + .unwrap_or(Uint128::zero()); + PENDING_DISTRIBUTION.save(deps.storage, addr.clone(), &(pending.checked_add(amount)?))?; + spent = spent.checked_add(amount)?; + resp = resp + .add_attribute("address", addr) + .add_attribute("amount", amount); + } + let remaining = funds.checked_sub(spent)?; + if !remaining.is_zero() { + let index = fund_counter % shares.len() as u64; + let key = &shares.get(index as usize).unwrap().0; + let pending = PENDING_DISTRIBUTION + .may_load(deps.storage, key.clone())? + .unwrap_or(Uint128::zero()); + PENDING_DISTRIBUTION.save( + deps.storage, + key.clone(), + &(pending.checked_add(remaining)?), + )?; + resp = resp + .add_attribute("remainder_address", key) + .add_attribute("remainder_amount", remaining); + } + FUND_COUNTER.save(deps.storage, &(fund_counter + 1))?; + Ok(resp) +} + +pub fn execute_set_shares( + deps: DepsMut, + info: MessageInfo, + shares: Vec<(String, Uint128)>, +) -> StdResult { + let config: Config = CONFIG.load(deps.storage)?; + if info.sender != config.owner { + return Err(StdError::generic_err("unauthorized")); + } + let mut new_shares = Vec::with_capacity(shares.len()); + for (addr, share) in shares { + let addr = deps.api.addr_validate(&addr)?; + new_shares.push((addr, share)); + } + remove_all_shares(deps.storage)?; + for (addr, shares) in new_shares.iter() { + SHARES.save(deps.storage, addr.clone(), shares)?; + } + Ok(Response::new() + .add_attribute("action", "neutron/distribution/set_shares") + .add_attribute("shares", format!("{:?}", new_shares))) +} + +pub fn remove_all_shares(storage: &mut dyn Storage) -> StdResult<()> { + let shares = SHARES + .keys(storage, None, None, Order::Ascending) + .collect::>>()?; + for addr in shares { + SHARES.remove(storage, addr); + } + Ok(()) +} + +pub fn execute_claim(deps: DepsMut, info: MessageInfo) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let denom = config.denom; + let sender = info.sender; + let pending = PENDING_DISTRIBUTION + .may_load(deps.storage, sender.clone())? + .unwrap_or(Uint128::zero()); + if pending.is_zero() { + return Err(StdError::generic_err("no pending distribution")); + } + PENDING_DISTRIBUTION.remove(deps.storage, sender.clone()); + Ok(Response::new().add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: sender.to_string(), + amount: vec![Coin { + denom, + amount: pending, + }], + }))) +} + +//-------------------------------------------------------------------------------------------------- +// Queries +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_binary(&query_config(deps)?), + QueryMsg::Pending {} => to_binary(&query_pending(deps)?), + QueryMsg::Shares {} => to_binary(&query_shares(deps)?), + } +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(config) +} + +pub fn query_shares(deps: Deps) -> StdResult> { + let shares = SHARES + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + Ok(shares) +} + +pub fn query_pending(deps: Deps) -> StdResult> { + let pending = PENDING_DISTRIBUTION + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + Ok(pending) +} diff --git a/contracts/distribution/src/lib.rs b/contracts/distribution/src/lib.rs new file mode 100644 index 00000000..3b33d5fd --- /dev/null +++ b/contracts/distribution/src/lib.rs @@ -0,0 +1,5 @@ +pub mod contract; +pub mod msg; +pub mod state; +#[cfg(test)] +pub mod testing; diff --git a/contracts/distribution/src/msg.rs b/contracts/distribution/src/msg.rs new file mode 100644 index 00000000..12547595 --- /dev/null +++ b/contracts/distribution/src/msg.rs @@ -0,0 +1,52 @@ +use cosmwasm_std::{Addr, Uint128}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct InstantiateMsg { + /// The contract's owner + pub owner: String, + pub denom: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + /// Transfer the contract's ownership to another account + TransferOwnership(String), + + SetShares { + shares: Vec<(String, Uint128)>, + }, + + /// Distribute funds between share holders. It is called from treasury contract only + /// when part of the fund is going to distribution betrween share holders. + Fund {}, + + /// Claim the funds that have been distributed to the contract's account + Claim {}, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// The contract's configurations; returns [`ConfigResponse`] + Config {}, + Pending {}, + Shares {}, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct StatsResponse { + pub total_received: Uint128, + pub total_distributed: Uint128, + pub last_balance: Uint128, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct ShareResponse { + address: Addr, + shares: Uint128, +} diff --git a/contracts/distribution/src/state.rs b/contracts/distribution/src/state.rs new file mode 100644 index 00000000..1b001040 --- /dev/null +++ b/contracts/distribution/src/state.rs @@ -0,0 +1,18 @@ +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::{Item, Map}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Config { + pub denom: String, + pub owner: Addr, +} +/// Map to store the amount of funds that are pending distribution to a given address +pub const PENDING_DISTRIBUTION: Map = Map::new("pending_distribution"); +/// Map to store the amount of shares that a given address has +pub const SHARES: Map = Map::new("shares"); + +pub const CONFIG: Item = Item::new("config"); + +pub const FUND_COUNTER: Item = Item::new("fund_counter"); diff --git a/contracts/distribution/src/testing/mock_querier.rs b/contracts/distribution/src/testing/mock_querier.rs new file mode 100644 index 00000000..df2eaa1f --- /dev/null +++ b/contracts/distribution/src/testing/mock_querier.rs @@ -0,0 +1,22 @@ +use std::marker::PhantomData; + +use cosmwasm_std::{ + testing::{MockApi, MockQuerier, MockStorage}, + Coin, OwnedDeps, +}; + +const MOCK_CONTRACT_ADDR: &str = "cosmos2contract"; + +pub fn mock_dependencies( + contract_balance: &[Coin], +) -> OwnedDeps { + let contract_addr = MOCK_CONTRACT_ADDR; + let custom_querier = MockQuerier::new(&[(contract_addr, contract_balance)]); + + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: custom_querier, + custom_query_type: PhantomData, + } +} diff --git a/contracts/distribution/src/testing/mod.rs b/contracts/distribution/src/testing/mod.rs new file mode 100644 index 00000000..a1e507b6 --- /dev/null +++ b/contracts/distribution/src/testing/mod.rs @@ -0,0 +1,2 @@ +mod mock_querier; +mod tests; diff --git a/contracts/distribution/src/testing/tests.rs b/contracts/distribution/src/testing/tests.rs new file mode 100644 index 00000000..6458604e --- /dev/null +++ b/contracts/distribution/src/testing/tests.rs @@ -0,0 +1,316 @@ +use cosmwasm_std::{ + coin, coins, from_binary, + testing::{mock_env, mock_info}, + Addr, BankMsg, CosmosMsg, DepsMut, Empty, Uint128, +}; + +use crate::{ + contract::{execute, instantiate, query}, + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + state::{CONFIG, FUND_COUNTER, PENDING_DISTRIBUTION, SHARES}, + testing::mock_querier::mock_dependencies, +}; + +const DENOM: &str = "denom"; + +pub fn init_base_contract(deps: DepsMut) { + let msg = InstantiateMsg { + denom: DENOM.to_string(), + owner: "owner".to_string(), + }; + let info = mock_info("creator", &coins(2, DENOM)); + instantiate(deps, mock_env(), info, msg).unwrap(); +} + +#[test] +fn test_transfer_ownership() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::TransferOwnership("new_owner".to_string()); + let res = execute(deps.as_mut(), mock_env(), mock_info("owner", &[]), msg); + assert!(res.is_ok()); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.owner.to_string(), "new_owner".to_string()); +} + +#[test] +fn test_fund_no_funds() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::Fund {}; + let res = execute(deps.as_mut(), mock_env(), mock_info("someone", &[]), msg); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().to_string(), "Generic error: no funds sent"); +} + +#[test] +fn test_fund_no_shares() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::Fund {}; + let res = execute( + deps.as_mut(), + mock_env(), + mock_info("someone", &[coin(10000u128, DENOM)]), + msg, + ); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().to_string(), "Generic error: no shares set"); +} + +#[test] +fn test_fund_success() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr1"), + &Uint128::from(1u128), + ) + .unwrap(); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr2"), + &Uint128::from(3u128), + ) + .unwrap(); + let msg = ExecuteMsg::Fund {}; + let res = execute( + deps.as_mut(), + mock_env(), + mock_info("someone", &[coin(10000u128, DENOM)]), + msg, + ); + assert!(res.is_ok()); + assert_eq!( + PENDING_DISTRIBUTION + .load(deps.as_ref().storage, Addr::unchecked("addr1")) + .unwrap(), + Uint128::from(2500u128) + ); + assert_eq!( + PENDING_DISTRIBUTION + .load(deps.as_ref().storage, Addr::unchecked("addr2")) + .unwrap(), + Uint128::from(7500u128) + ); + let fund_counter = FUND_COUNTER.load(deps.as_ref().storage).unwrap(); + assert_eq!(fund_counter, 1u64); +} + +#[test] +fn test_fund_success_with_dust() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr1"), + &Uint128::from(1u128), + ) + .unwrap(); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr2"), + &Uint128::from(3u128), + ) + .unwrap(); + let msg = ExecuteMsg::Fund {}; + let res = execute( + deps.as_mut(), + mock_env(), + mock_info("someone", &[coin(10001u128, DENOM)]), + msg, + ); + assert!(res.is_ok()); + println!("{:?}", res.unwrap().attributes); + assert_eq!( + PENDING_DISTRIBUTION + .load(deps.as_ref().storage, Addr::unchecked("addr1")) + .unwrap(), + Uint128::from(2501u128) + ); + assert_eq!( + PENDING_DISTRIBUTION + .load(deps.as_ref().storage, Addr::unchecked("addr2")) + .unwrap(), + Uint128::from(7500u128) + ); + let fund_counter = FUND_COUNTER.load(deps.as_ref().storage).unwrap(); + assert_eq!(fund_counter, 1u64); +} + +#[test] +fn test_withdraw_no_pending() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::Claim {}; + let res = execute(deps.as_mut(), mock_env(), mock_info("someone", &[]), msg); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "Generic error: no pending distribution" + ); +} + +#[test] +fn test_withdraw_success() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + PENDING_DISTRIBUTION + .save( + deps.as_mut().storage, + Addr::unchecked("addr1"), + &Uint128::from(1000u128), + ) + .unwrap(); + let msg = ExecuteMsg::Claim {}; + let res = execute(deps.as_mut(), mock_env(), mock_info("addr1", &[]), msg); + assert!(res.is_ok()); + // check message + let messages = res.unwrap().messages; + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].msg, + CosmosMsg::Bank(BankMsg::Send { + to_address: "addr1".to_string(), + amount: vec![coin(1000u128, DENOM)], + }) + ); + assert_eq!( + PENDING_DISTRIBUTION + .may_load(deps.as_ref().storage, Addr::unchecked("addr1")) + .unwrap(), + None + ); +} + +#[test] +fn test_set_shares_unauthorized() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::SetShares { + shares: vec![("addr1".to_string(), Uint128::from(1u128))], + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("someone", &[]), msg); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().to_string(), "Generic error: unauthorized"); +} + +#[test] +fn test_set_shares() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr1"), + &Uint128::from(1u128), + ) + .unwrap(); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr2"), + &Uint128::from(3u128), + ) + .unwrap(); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr3"), + &Uint128::from(3u128), + ) + .unwrap(); + let msg = ExecuteMsg::SetShares { + shares: vec![ + ("addr1".to_string(), Uint128::from(1u128)), + ("addr2".to_string(), Uint128::from(2u128)), + ], + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("owner", &[]), msg); + assert!(res.is_ok()); + assert_eq!( + SHARES + .load(deps.as_ref().storage, Addr::unchecked("addr1")) + .unwrap(), + Uint128::from(1u128) + ); + assert_eq!( + SHARES + .load(deps.as_ref().storage, Addr::unchecked("addr2")) + .unwrap(), + Uint128::from(2u128) + ); + assert_eq!( + SHARES + .may_load(deps.as_ref().storage, Addr::unchecked("addr3")) + .unwrap(), + None + ); +} + +#[test] +fn test_query_shares() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr1"), + &Uint128::from(1u128), + ) + .unwrap(); + SHARES + .save( + deps.as_mut().storage, + Addr::unchecked("addr2"), + &Uint128::from(3u128), + ) + .unwrap(); + let msg = QueryMsg::Shares {}; + let res = query(deps.as_ref(), mock_env(), msg); + assert!(res.is_ok()); + let value: Vec<(String, Uint128)> = from_binary(&res.unwrap()).unwrap(); + assert_eq!( + value, + vec![ + ("addr1".to_string(), Uint128::from(1u128)), + ("addr2".to_string(), Uint128::from(3u128)) + ] + ); +} + +#[test] +fn test_query_pending() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + PENDING_DISTRIBUTION + .save( + deps.as_mut().storage, + Addr::unchecked("addr1"), + &Uint128::from(1u128), + ) + .unwrap(); + PENDING_DISTRIBUTION + .save( + deps.as_mut().storage, + Addr::unchecked("addr2"), + &Uint128::from(3u128), + ) + .unwrap(); + let msg = QueryMsg::Pending {}; + let res = query(deps.as_ref(), mock_env(), msg); + assert!(res.is_ok()); + let value: Vec<(String, Uint128)> = from_binary(&res.unwrap()).unwrap(); + assert_eq!( + value, + vec![ + ("addr1".to_string(), Uint128::from(1u128)), + ("addr2".to_string(), Uint128::from(3u128)) + ] + ); +} diff --git a/contracts/reserve/.cargo/config b/contracts/reserve/.cargo/config new file mode 100644 index 00000000..7c115322 --- /dev/null +++ b/contracts/reserve/.cargo/config @@ -0,0 +1,6 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib --features backtraces" +integration-test = "test --test integration" +schema = "run --example schema" diff --git a/contracts/reserve/Cargo.toml b/contracts/reserve/Cargo.toml new file mode 100644 index 00000000..00a738f2 --- /dev/null +++ b/contracts/reserve/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "neutron-reserve" +version = "0.1.0" +authors = ["Sergey Ratiashvili "] +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/neutron/neutron-dao" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] + +[dependencies] +cosmwasm-std = { version = "1.0" } +cw-storage-plus = "1.0.1" +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } + +[dev-dependencies] +cosmwasm-schema = { version = "1.0.0", default-features = false } diff --git a/contracts/reserve/Makefile b/contracts/reserve/Makefile new file mode 100644 index 00000000..e2aa6a26 --- /dev/null +++ b/contracts/reserve/Makefile @@ -0,0 +1,9 @@ +CURRENT_DIR = $(shell pwd) +CURRENT_DIR_RELATIVE = $(notdir $(shell pwd)) + +clippy: + rustup component add clippy || true + cargo clippy --all-targets --all-features --workspace -- -D warnings + +test: clippy + cargo unit-test diff --git a/contracts/reserve/README.md b/contracts/reserve/README.md new file mode 100644 index 00000000..9db39578 --- /dev/null +++ b/contracts/reserve/README.md @@ -0,0 +1 @@ +# Neutron treasury diff --git a/contracts/reserve/examples/schema.rs b/contracts/reserve/examples/schema.rs new file mode 100644 index 00000000..9ebb4dec --- /dev/null +++ b/contracts/reserve/examples/schema.rs @@ -0,0 +1,31 @@ +// Copyright 2022 Neutron +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; + +use neutron_reserve::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); +} diff --git a/contracts/reserve/schema/execute_msg.json b/contracts/reserve/schema/execute_msg.json new file mode 100644 index 00000000..c0803a5b --- /dev/null +++ b/contracts/reserve/schema/execute_msg.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Transfer the contract's ownership to another account", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "payout" + ], + "properties": { + "payout": { + "type": "object", + "required": [ + "amount", + "recipient" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "recipient": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "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" + } + } +} diff --git a/contracts/reserve/schema/instantiate_msg.json b/contracts/reserve/schema/instantiate_msg.json new file mode 100644 index 00000000..955cece9 --- /dev/null +++ b/contracts/reserve/schema/instantiate_msg.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "denom", + "owner" + ], + "properties": { + "denom": { + "type": "string" + }, + "owner": { + "type": "string" + } + } +} diff --git a/contracts/reserve/schema/query_msg.json b/contracts/reserve/schema/query_msg.json new file mode 100644 index 00000000..d3f74fb2 --- /dev/null +++ b/contracts/reserve/schema/query_msg.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "The contract's configuration", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object" + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/reserve/src/contract.rs b/contracts/reserve/src/contract.rs new file mode 100644 index 00000000..fb49c68e --- /dev/null +++ b/contracts/reserve/src/contract.rs @@ -0,0 +1,118 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + coins, to_binary, Addr, BankMsg, Binary, CosmosMsg, Deps, DepsMut, Env, MessageInfo, Response, + StdError, StdResult, Uint128, +}; + +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{Config, CONFIG}; + +//-------------------------------------------------------------------------------------------------- +// Instantiation +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + let config = Config { + denom: msg.denom, + owner: deps.api.addr_validate(&msg.owner)?, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new()) +} + +//-------------------------------------------------------------------------------------------------- +// Executions +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> StdResult { + let api = deps.api; + match msg { + // permissioned - owner + ExecuteMsg::TransferOwnership(new_owner) => { + execute_transfer_ownership(deps, info, api.addr_validate(&new_owner)?) + } + ExecuteMsg::Payout { amount, recipient } => { + execute_payout(deps, info, env, amount, recipient) + } + } +} + +pub fn execute_transfer_ownership( + deps: DepsMut, + info: MessageInfo, + new_owner_addr: Addr, +) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let sender_addr = info.sender; + let old_owner = config.owner; + if sender_addr != old_owner { + return Err(StdError::generic_err("unauthorized")); + } + + CONFIG.update(deps.storage, |mut config| -> StdResult<_> { + config.owner = new_owner_addr.clone(); + Ok(config) + })?; + + Ok(Response::new() + .add_attribute("action", "neutron/treasury/transfer_ownership") + .add_attribute("previous_owner", old_owner) + .add_attribute("new_owner", new_owner_addr)) +} + +pub fn execute_payout( + deps: DepsMut, + info: MessageInfo, + env: Env, + amount: Uint128, + recipient: String, +) -> StdResult { + let config: Config = CONFIG.load(deps.storage)?; + let denom = config.denom; + if info.sender != config.owner { + return Err(StdError::generic_err("unauthorized")); + } + // verify that the contract has enough funds + let bank_balance = deps + .querier + .query_balance(env.contract.address, &denom)? + .amount; + + if amount.gt(&bank_balance) { + return Err(StdError::generic_err("insufficient funds")); + } + + Ok(Response::new() + .add_message(CosmosMsg::Bank(BankMsg::Send { + to_address: recipient.clone(), + amount: coins(amount.u128(), denom), + })) + .add_attribute("action", "neutron/treasury/payout") + .add_attribute("amount", amount) + .add_attribute("recipient", recipient)) +} + +//-------------------------------------------------------------------------------------------------- +// Queries +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_binary(&query_config(deps)?), + } +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(config) +} diff --git a/contracts/reserve/src/lib.rs b/contracts/reserve/src/lib.rs new file mode 100644 index 00000000..2a287a05 --- /dev/null +++ b/contracts/reserve/src/lib.rs @@ -0,0 +1,5 @@ +pub mod contract; +pub mod msg; +pub mod state; +#[cfg(test)] +mod testing; diff --git a/contracts/reserve/src/msg.rs b/contracts/reserve/src/msg.rs new file mode 100644 index 00000000..57e07a8e --- /dev/null +++ b/contracts/reserve/src/msg.rs @@ -0,0 +1,28 @@ +use cosmwasm_std::Uint128; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct InstantiateMsg { + pub owner: String, + pub denom: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + /// Transfer the contract's ownership to another account + TransferOwnership(String), + // Payout funds at DAO decision + Payout { + amount: Uint128, + recipient: String, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// The contract's configuration + Config {}, +} diff --git a/contracts/reserve/src/state.rs b/contracts/reserve/src/state.rs new file mode 100644 index 00000000..438f9bb9 --- /dev/null +++ b/contracts/reserve/src/state.rs @@ -0,0 +1,12 @@ +use cosmwasm_std::Addr; +use cw_storage_plus::Item; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Config { + pub denom: String, + pub owner: Addr, +} + +pub const CONFIG: Item = Item::new("config"); diff --git a/contracts/reserve/src/testing/mock_querier.rs b/contracts/reserve/src/testing/mock_querier.rs new file mode 100644 index 00000000..df2eaa1f --- /dev/null +++ b/contracts/reserve/src/testing/mock_querier.rs @@ -0,0 +1,22 @@ +use std::marker::PhantomData; + +use cosmwasm_std::{ + testing::{MockApi, MockQuerier, MockStorage}, + Coin, OwnedDeps, +}; + +const MOCK_CONTRACT_ADDR: &str = "cosmos2contract"; + +pub fn mock_dependencies( + contract_balance: &[Coin], +) -> OwnedDeps { + let contract_addr = MOCK_CONTRACT_ADDR; + let custom_querier = MockQuerier::new(&[(contract_addr, contract_balance)]); + + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: custom_querier, + custom_query_type: PhantomData, + } +} diff --git a/contracts/reserve/src/testing/mod.rs b/contracts/reserve/src/testing/mod.rs new file mode 100644 index 00000000..a1e507b6 --- /dev/null +++ b/contracts/reserve/src/testing/mod.rs @@ -0,0 +1,2 @@ +mod mock_querier; +mod tests; diff --git a/contracts/reserve/src/testing/tests.rs b/contracts/reserve/src/testing/tests.rs new file mode 100644 index 00000000..91cab54c --- /dev/null +++ b/contracts/reserve/src/testing/tests.rs @@ -0,0 +1,88 @@ +use cosmwasm_std::{ + coin, coins, + testing::{mock_env, mock_info}, + BankMsg, Coin, CosmosMsg, DepsMut, Empty, Uint128, +}; + +use crate::{ + contract::{execute, instantiate}, + msg::{ExecuteMsg, InstantiateMsg}, + state::CONFIG, + testing::mock_querier::mock_dependencies, +}; + +const DENOM: &str = "denom"; + +pub fn init_base_contract(deps: DepsMut) { + let msg = InstantiateMsg { + denom: DENOM.to_string(), + + owner: "owner".to_string(), + }; + let info = mock_info("creator", &coins(2, DENOM)); + instantiate(deps, mock_env(), info, msg).unwrap(); +} + +#[test] +fn test_transfer_ownership() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::TransferOwnership("new_owner".to_string()); + let res = execute(deps.as_mut(), mock_env(), mock_info("owner", &[]), msg); + assert!(res.is_ok()); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.owner.to_string(), "new_owner".to_string()); +} + +#[test] +fn test_payout_no_money() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::Payout { + amount: Uint128::from(500000u128), + recipient: "some".to_string(), + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("owner", &[]), msg); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "Generic error: insufficient funds" + ); +} + +#[test] +fn test_payout_not_owner() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::Payout { + amount: Uint128::from(500000u128), + recipient: "some".to_string(), + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("not_owner", &[]), msg); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().to_string(), "Generic error: unauthorized"); +} + +#[test] +fn test_payout_success() { + let mut deps = mock_dependencies(&[coin(1000000, DENOM)]); + init_base_contract(deps.as_mut()); + let msg = ExecuteMsg::Payout { + amount: Uint128::from(400000u128), + recipient: "some".to_string(), + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("owner", &[]), msg); + assert!(res.is_ok()); + let messages = res.unwrap().messages; + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].msg, + CosmosMsg::Bank(BankMsg::Send { + to_address: "some".to_string(), + amount: vec![Coin { + denom: DENOM.to_string(), + amount: Uint128::from(400000u128) + }], + }) + ); +} diff --git a/contracts/treasury/.cargo/config b/contracts/treasury/.cargo/config new file mode 100644 index 00000000..7c115322 --- /dev/null +++ b/contracts/treasury/.cargo/config @@ -0,0 +1,6 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib --features backtraces" +integration-test = "test --test integration" +schema = "run --example schema" diff --git a/contracts/treasury/Cargo.toml b/contracts/treasury/Cargo.toml new file mode 100644 index 00000000..38a9c06d --- /dev/null +++ b/contracts/treasury/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "neutron-treasury" +version = "0.1.0" +authors = ["Sergey Ratiashvili "] +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/neutron/neutron-dao" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] + +[dependencies] +cosmwasm-std = { version = "1.0" } +cw-storage-plus = "1.0.1" +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } + +[dev-dependencies] +cosmwasm-schema = { version = "1.0.0", default-features = false } diff --git a/contracts/treasury/Makefile b/contracts/treasury/Makefile new file mode 100644 index 00000000..e2aa6a26 --- /dev/null +++ b/contracts/treasury/Makefile @@ -0,0 +1,9 @@ +CURRENT_DIR = $(shell pwd) +CURRENT_DIR_RELATIVE = $(notdir $(shell pwd)) + +clippy: + rustup component add clippy || true + cargo clippy --all-targets --all-features --workspace -- -D warnings + +test: clippy + cargo unit-test diff --git a/contracts/treasury/README.md b/contracts/treasury/README.md new file mode 100644 index 00000000..9db39578 --- /dev/null +++ b/contracts/treasury/README.md @@ -0,0 +1 @@ +# Neutron treasury diff --git a/contracts/treasury/examples/schema.rs b/contracts/treasury/examples/schema.rs new file mode 100644 index 00000000..7fa49979 --- /dev/null +++ b/contracts/treasury/examples/schema.rs @@ -0,0 +1,31 @@ +// Copyright 2022 Neutron +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; + +use neutron_treasury::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); +} diff --git a/contracts/treasury/schema/execute_msg.json b/contracts/treasury/schema/execute_msg.json new file mode 100644 index 00000000..991dcceb --- /dev/null +++ b/contracts/treasury/schema/execute_msg.json @@ -0,0 +1,82 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Transfer the contract's ownership to another account", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "Distribute pending funds between Bank and Distribution accounts", + "type": "object", + "required": [ + "distribute" + ], + "properties": { + "distribute": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "distribution_contract": { + "type": [ + "string", + "null" + ] + }, + "distribution_rate": { + "anyOf": [ + { + "$ref": "#/definitions/Decimal" + }, + { + "type": "null" + } + ] + }, + "min_period": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "reserve_contract": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + } + } +} diff --git a/contracts/treasury/schema/instantiate_msg.json b/contracts/treasury/schema/instantiate_msg.json new file mode 100644 index 00000000..cdd51d4c --- /dev/null +++ b/contracts/treasury/schema/instantiate_msg.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "denom", + "distribution_contract", + "distribution_rate", + "min_period", + "owner", + "reserve_contract" + ], + "properties": { + "denom": { + "type": "string" + }, + "distribution_contract": { + "description": "Address of distribution contract", + "type": "string" + }, + "distribution_rate": { + "description": "Distribution rate (0-1) which goes to distribution contract", + "allOf": [ + { + "$ref": "#/definitions/Decimal" + } + ] + }, + "min_period": { + "description": "Minimum period between distribution calls", + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "owner": { + "type": "string" + }, + "reserve_contract": { + "description": "Address of reserve contract", + "type": "string" + } + }, + "definitions": { + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + } + } +} diff --git a/contracts/treasury/schema/query_msg.json b/contracts/treasury/schema/query_msg.json new file mode 100644 index 00000000..967b89ed --- /dev/null +++ b/contracts/treasury/schema/query_msg.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "The contract's configurations; returns [`ConfigResponse`]", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "stats" + ], + "properties": { + "stats": { + "type": "object" + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/treasury/src/contract.rs b/contracts/treasury/src/contract.rs new file mode 100644 index 00000000..5491fdff --- /dev/null +++ b/contracts/treasury/src/contract.rs @@ -0,0 +1,224 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + coins, to_binary, Addr, BankMsg, Binary, CosmosMsg, Decimal, Deps, DepsMut, Env, MessageInfo, + Response, StdError, StdResult, Uint128, WasmMsg, +}; + +use crate::msg::{DistributeMsg, ExecuteMsg, InstantiateMsg, QueryMsg, StatsResponse}; +use crate::state::{ + Config, CONFIG, LAST_DISTRIBUTION_TIME, TOTAL_DISTRIBUTED, TOTAL_RECEIVED, TOTAL_RESERVED, +}; + +//-------------------------------------------------------------------------------------------------- +// Instantiation +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + let config = Config { + denom: msg.denom, + min_period: msg.min_period, + distribution_contract: deps.api.addr_validate(msg.distribution_contract.as_str())?, + reserve_contract: deps.api.addr_validate(msg.reserve_contract.as_str())?, + distribution_rate: msg.distribution_rate, + owner: deps.api.addr_validate(&msg.owner)?, + }; + CONFIG.save(deps.storage, &config)?; + TOTAL_RECEIVED.save(deps.storage, &Uint128::zero())?; + TOTAL_DISTRIBUTED.save(deps.storage, &Uint128::zero())?; + TOTAL_RESERVED.save(deps.storage, &Uint128::zero())?; + LAST_DISTRIBUTION_TIME.save(deps.storage, &0)?; + + Ok(Response::new()) +} + +//-------------------------------------------------------------------------------------------------- +// Executions +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> StdResult { + let api = deps.api; + match msg { + // permissioned - owner + ExecuteMsg::TransferOwnership(new_owner) => { + execute_transfer_ownership(deps, info, api.addr_validate(&new_owner)?) + } + // permissionless + ExecuteMsg::Distribute {} => execute_distribute(deps, env), + + // permissioned - owner + ExecuteMsg::UpdateConfig { + distribution_rate, + min_period, + distribution_contract, + reserve_contract, + } => execute_update_config( + deps, + info, + distribution_rate, + min_period, + distribution_contract, + reserve_contract, + ), + } +} + +pub fn execute_transfer_ownership( + deps: DepsMut, + info: MessageInfo, + new_owner_addr: Addr, +) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let sender_addr = info.sender; + let old_owner = config.owner; + if sender_addr != old_owner { + return Err(StdError::generic_err("unauthorized")); + } + + CONFIG.update(deps.storage, |mut config| -> StdResult<_> { + config.owner = new_owner_addr.clone(); + Ok(config) + })?; + + Ok(Response::new() + .add_attribute("action", "neutron/treasury/transfer_ownership") + .add_attribute("previous_owner", old_owner) + .add_attribute("new_owner", new_owner_addr)) +} + +pub fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + distribution_rate: Option, + min_period: Option, + distribution_contract: Option, + reserve_contract: Option, +) -> StdResult { + let mut config: Config = CONFIG.load(deps.storage)?; + if info.sender != config.owner { + return Err(StdError::generic_err("unauthorized")); + } + + if let Some(min_period) = min_period { + config.min_period = min_period; + } + if let Some(distribution_contract) = distribution_contract { + config.distribution_contract = deps.api.addr_validate(distribution_contract.as_str())?; + } + if let Some(reserve_contract) = reserve_contract { + config.reserve_contract = deps.api.addr_validate(reserve_contract.as_str())?; + } + if let Some(distribution_rate) = distribution_rate { + if (distribution_rate > Decimal::one()) || (distribution_rate < Decimal::zero()) { + return Err(StdError::generic_err( + "distribution_rate must be between 0 and 1", + )); + } + config.distribution_rate = distribution_rate; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("action", "neutron/treasury/update_config") + .add_attribute("denom", config.denom) + .add_attribute("min_period", config.min_period.to_string()) + .add_attribute("distribution_contract", config.distribution_contract) + .add_attribute("distribution_rate", config.distribution_rate.to_string()) + .add_attribute("owner", config.owner)) +} + +pub fn execute_distribute(deps: DepsMut, env: Env) -> StdResult { + let config: Config = CONFIG.load(deps.storage)?; + let denom = config.denom; + let current_time = env.block.time.seconds(); + if current_time - LAST_DISTRIBUTION_TIME.load(deps.storage)? < config.min_period { + return Err(StdError::generic_err("too soon to distribute")); + } + LAST_DISTRIBUTION_TIME.save(deps.storage, ¤t_time)?; + let current_balance = deps + .querier + .query_balance(env.contract.address, &denom)? + .amount; + + if current_balance.is_zero() { + return Err(StdError::GenericErr { + msg: "no new funds to distribute".to_string(), + }); + } + + let to_distribute = current_balance * config.distribution_rate; + let to_reserve = current_balance.checked_sub(to_distribute)?; + // update stats + let total_received = TOTAL_RECEIVED.load(deps.storage)?; + TOTAL_RECEIVED.save( + deps.storage, + &(total_received.checked_add(current_balance)?), + )?; + let total_distributed = TOTAL_DISTRIBUTED.load(deps.storage)?; + TOTAL_DISTRIBUTED.save( + deps.storage, + &(total_distributed.checked_add(to_distribute)?), + )?; + let total_reserved = TOTAL_RESERVED.load(deps.storage)?; + TOTAL_RESERVED.save(deps.storage, &(total_reserved.checked_add(to_reserve)?))?; + + let mut resp = Response::default(); + if !to_distribute.is_zero() { + let msg = CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: config.distribution_contract.to_string(), + funds: coins(to_distribute.u128(), denom.clone()), + msg: to_binary(&DistributeMsg::Fund {})?, + }); + resp = resp.add_message(msg) + } + + if !to_reserve.is_zero() { + let msg = CosmosMsg::Bank(BankMsg::Send { + to_address: config.reserve_contract.to_string(), + amount: coins(to_reserve.u128(), denom), + }); + resp = resp.add_message(msg); + } + + Ok(resp + .add_attribute("action", "neutron/treasury/distribute") + .add_attribute("reserved", to_reserve) + .add_attribute("distributed", to_distribute)) +} + +//-------------------------------------------------------------------------------------------------- +// Queries +//-------------------------------------------------------------------------------------------------- + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_binary(&query_config(deps)?), + QueryMsg::Stats {} => to_binary(&query_stats(deps)?), + } +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(config) +} + +pub fn query_stats(deps: Deps) -> StdResult { + let total_received = TOTAL_RECEIVED.load(deps.storage)?; + let total_distributed = TOTAL_DISTRIBUTED.load(deps.storage)?; + let total_reserved = TOTAL_RESERVED.load(deps.storage)?; + + Ok(StatsResponse { + total_received, + total_distributed, + total_reserved, + }) +} diff --git a/contracts/treasury/src/lib.rs b/contracts/treasury/src/lib.rs new file mode 100644 index 00000000..2a287a05 --- /dev/null +++ b/contracts/treasury/src/lib.rs @@ -0,0 +1,5 @@ +pub mod contract; +pub mod msg; +pub mod state; +#[cfg(test)] +mod testing; diff --git a/contracts/treasury/src/msg.rs b/contracts/treasury/src/msg.rs new file mode 100644 index 00000000..58db1887 --- /dev/null +++ b/contracts/treasury/src/msg.rs @@ -0,0 +1,57 @@ +use cosmwasm_std::{Decimal, Uint128}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct InstantiateMsg { + pub owner: String, + pub denom: String, + /// Distribution rate (0-1) which goes to distribution contract + pub distribution_rate: Decimal, + /// Minimum period between distribution calls + pub min_period: u64, + /// Address of distribution contract + pub distribution_contract: String, + /// Address of reserve contract + pub reserve_contract: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + /// Transfer the contract's ownership to another account + TransferOwnership(String), + + /// Distribute pending funds between Bank and Distribution accounts + Distribute {}, + + // //Update config + UpdateConfig { + distribution_rate: Option, + min_period: Option, + distribution_contract: Option, + reserve_contract: Option, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// The contract's configurations; returns [`ConfigResponse`] + Config {}, + Stats {}, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct StatsResponse { + pub total_received: Uint128, + pub total_distributed: Uint128, + pub total_reserved: Uint128, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DistributeMsg { + Fund {}, +} diff --git a/contracts/treasury/src/state.rs b/contracts/treasury/src/state.rs new file mode 100644 index 00000000..9c18d4bd --- /dev/null +++ b/contracts/treasury/src/state.rs @@ -0,0 +1,26 @@ +use cosmwasm_std::{Addr, Decimal, Uint128}; +use cw_storage_plus::Item; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)] +pub struct Config { + /// Distribution rate (0-1) which goes to distribution contract + pub distribution_rate: Decimal, + /// Address of distribution contract, which will receive funds defined but distribution_rate % + pub distribution_contract: Addr, + /// Address of reserve contract, which will receive funds defined by 100-distribution_rate % + pub reserve_contract: Addr, + /// Minimum period between distribution calls + pub min_period: u64, + pub denom: String, + pub owner: Addr, +} + +pub const TOTAL_RECEIVED: Item = Item::new("total_received"); +pub const TOTAL_DISTRIBUTED: Item = Item::new("total_distributed"); +pub const TOTAL_RESERVED: Item = Item::new("total_reserved"); + +pub const LAST_DISTRIBUTION_TIME: Item = Item::new("last_grab_time"); + +pub const CONFIG: Item = Item::new("config"); diff --git a/contracts/treasury/src/testing/mock_querier.rs b/contracts/treasury/src/testing/mock_querier.rs new file mode 100644 index 00000000..df2eaa1f --- /dev/null +++ b/contracts/treasury/src/testing/mock_querier.rs @@ -0,0 +1,22 @@ +use std::marker::PhantomData; + +use cosmwasm_std::{ + testing::{MockApi, MockQuerier, MockStorage}, + Coin, OwnedDeps, +}; + +const MOCK_CONTRACT_ADDR: &str = "cosmos2contract"; + +pub fn mock_dependencies( + contract_balance: &[Coin], +) -> OwnedDeps { + let contract_addr = MOCK_CONTRACT_ADDR; + let custom_querier = MockQuerier::new(&[(contract_addr, contract_balance)]); + + OwnedDeps { + storage: MockStorage::default(), + api: MockApi::default(), + querier: custom_querier, + custom_query_type: PhantomData, + } +} diff --git a/contracts/treasury/src/testing/mod.rs b/contracts/treasury/src/testing/mod.rs new file mode 100644 index 00000000..a1e507b6 --- /dev/null +++ b/contracts/treasury/src/testing/mod.rs @@ -0,0 +1,2 @@ +mod mock_querier; +mod tests; diff --git a/contracts/treasury/src/testing/tests.rs b/contracts/treasury/src/testing/tests.rs new file mode 100644 index 00000000..22b7e6c0 --- /dev/null +++ b/contracts/treasury/src/testing/tests.rs @@ -0,0 +1,195 @@ +use std::str::FromStr; + +use cosmwasm_std::{ + coin, coins, + testing::{mock_env, mock_info}, + to_binary, BankMsg, Coin, CosmosMsg, Decimal, DepsMut, Empty, Uint128, WasmMsg, +}; + +use crate::{ + contract::{execute, instantiate}, + msg::{DistributeMsg, ExecuteMsg, InstantiateMsg}, + state::{CONFIG, TOTAL_DISTRIBUTED, TOTAL_RECEIVED, TOTAL_RESERVED}, + testing::mock_querier::mock_dependencies, +}; + +const DENOM: &str = "denom"; + +pub fn init_base_contract(deps: DepsMut, distribution_rate: &str) { + let msg = InstantiateMsg { + denom: DENOM.to_string(), + min_period: 1000, + distribution_contract: "distribution_contract".to_string(), + reserve_contract: "reserve_contract".to_string(), + distribution_rate: Decimal::from_str(distribution_rate).unwrap(), + owner: "owner".to_string(), + }; + let info = mock_info("creator", &coins(2, DENOM)); + instantiate(deps, mock_env(), info, msg).unwrap(); +} + +#[test] +fn test_transfer_ownership() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut(), "0.23"); + let msg = ExecuteMsg::TransferOwnership("new_owner".to_string()); + let res = execute(deps.as_mut(), mock_env(), mock_info("owner", &[]), msg); + assert!(res.is_ok()); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.owner.to_string(), "new_owner".to_string()); +} + +#[test] +fn test_collect_with_no_money() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut(), "1"); + let msg = ExecuteMsg::Distribute {}; + let res = execute(deps.as_mut(), mock_env(), mock_info("anyone", &[]), msg); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "Generic error: no new funds to distribute" + ); +} + +#[test] +fn test_distribute_success() { + let mut deps = mock_dependencies(&[coin(1000000, DENOM)]); + init_base_contract(deps.as_mut(), "0.23"); + let msg = ExecuteMsg::Distribute {}; + let res = execute(deps.as_mut(), mock_env(), mock_info("anyone", &[]), msg); + assert!(res.is_ok()); + let messages = res.unwrap().messages; + assert_eq!(messages.len(), 2); + assert_eq!( + messages[0].msg, + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "distribution_contract".to_string(), + funds: vec![Coin { + denom: DENOM.to_string(), + amount: Uint128::from(230000u128) + }], + msg: to_binary(&DistributeMsg::Fund {}).unwrap(), + }) + ); + assert_eq!( + messages[1].msg, + CosmosMsg::Bank(BankMsg::Send { + to_address: "reserve_contract".to_string(), + amount: vec![Coin { + denom: DENOM.to_string(), + amount: Uint128::from(770000u128) + }] + }) + ); + let total_received = TOTAL_RECEIVED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_received, Uint128::from(1000000u128)); + let total_reserved = TOTAL_RESERVED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_reserved, Uint128::from(770000u128)); + let total_distributed = TOTAL_DISTRIBUTED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_distributed, Uint128::from(230000u128)); +} + +#[test] +fn test_distribute_zero_to_reserve() { + let mut deps = mock_dependencies(&[coin(1000000, DENOM)]); + init_base_contract(deps.as_mut(), "1"); + let msg = ExecuteMsg::Distribute {}; + let res = execute(deps.as_mut(), mock_env(), mock_info("anyone", &[]), msg); + assert!(res.is_ok()); + let messages = res.unwrap().messages; + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].msg, + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: "distribution_contract".to_string(), + funds: vec![Coin { + denom: DENOM.to_string(), + amount: Uint128::from(1000000u128) + }], + msg: to_binary(&DistributeMsg::Fund {}).unwrap(), + }) + ); + + let total_received = TOTAL_RECEIVED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_received, Uint128::from(1000000u128)); + let total_reserved = TOTAL_RESERVED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_reserved, Uint128::from(0u128)); + let total_distributed = TOTAL_DISTRIBUTED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_distributed, Uint128::from(1000000u128)); +} + +#[test] +fn test_distribute_zero_to_distribution_contract() { + let mut deps = mock_dependencies(&[coin(1000000, DENOM)]); + init_base_contract(deps.as_mut(), "0"); + let msg = ExecuteMsg::Distribute {}; + let res = execute(deps.as_mut(), mock_env(), mock_info("anyone", &[]), msg); + assert!(res.is_ok()); + let messages = res.unwrap().messages; + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].msg, + CosmosMsg::Bank(BankMsg::Send { + to_address: "reserve_contract".to_string(), + amount: vec![Coin { + denom: DENOM.to_string(), + amount: Uint128::from(1000000u128) + }] + }) + ); + let total_received = TOTAL_RECEIVED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_received, Uint128::from(1000000u128)); + let total_reserved = TOTAL_RESERVED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_reserved, Uint128::from(1000000u128)); + let total_distributed = TOTAL_DISTRIBUTED.load(deps.as_ref().storage).unwrap(); + assert_eq!(total_distributed, Uint128::from(0u128)); +} + +#[test] +fn test_update_config_unauthorized() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut(), "1"); + let msg = ExecuteMsg::UpdateConfig { + distribution_contract: None, + reserve_contract: None, + distribution_rate: None, + min_period: None, + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("not_owner", &[]), msg); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().to_string(), "Generic error: unauthorized"); +} + +#[test] +fn test_update_config_success() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut(), "1"); + let msg = ExecuteMsg::UpdateConfig { + distribution_contract: Some("new_contract".to_string()), + reserve_contract: Some("new_reserve_contract".to_string()), + distribution_rate: Some(Decimal::from_str("0.11").unwrap()), + min_period: Some(3000), + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("owner", &[]), msg); + assert!(res.is_ok()); + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.distribution_contract, "new_contract"); + assert_eq!(config.reserve_contract, "new_reserve_contract"); + assert_eq!(config.distribution_rate, Decimal::from_str("0.11").unwrap()); + assert_eq!(config.min_period, 3000); +} + +#[test] +fn test_update_distribution_rate_below_the_limit() { + let mut deps = mock_dependencies(&[]); + init_base_contract(deps.as_mut(), "1"); + let msg = ExecuteMsg::UpdateConfig { + distribution_contract: None, + reserve_contract: None, + distribution_rate: Some(Decimal::from_str("2").unwrap()), + min_period: None, + }; + let res = execute(deps.as_mut(), mock_env(), mock_info("owner", &[]), msg); + assert!(res.is_err()); +}