diff --git a/.circleci/config.yml b/.circleci/config.yml index 9c1dd7c9..63617756 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,6 +4,7 @@ workflows: test: jobs: - contract_tg4_engagement + - contract_tg4_group - contract_tg4_mixer - contract_tg4_stake - contract_tgrade_community_pool @@ -65,6 +66,33 @@ jobs: - target key: cargocache-tg4-engagement-rust:1.58.1-{{ checksum "~/project/Cargo.lock" }} + contract_tg4_group: + docker: + - image: rust:1.58.1 + working_directory: ~/project/contracts/tg4-group + steps: + - checkout: + path: ~/project + - run: + name: Version information + command: rustc --version; cargo --version; rustup --version + - restore_cache: + keys: + - cargocache-tg4-group-rust:1.58.1-{{ checksum "~/project/Cargo.lock" }} + - run: + name: Unit Tests + environment: + RUST_BACKTRACE: 1 + command: cargo unit-test --locked + - run: + name: Build and run schema generator + command: cargo schema --locked + - save_cache: + paths: + - /usr/local/cargo/registry + - target + key: cargocache-tg4-group-rust:1.58.1-{{ checksum "~/project/Cargo.lock" }} + contract_tg4_mixer: docker: - image: rust:1.58.1 diff --git a/Cargo.lock b/Cargo.lock index 98bbbc15..65930f47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1579,6 +1579,24 @@ dependencies = [ "thiserror", ] +[[package]] +name = "tg4-group" +version = "0.6.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw-utils", + "cw2", + "schemars", + "serde", + "tg-bindings", + "tg-utils", + "tg4", + "thiserror", +] + [[package]] name = "tg4-mixer" version = "0.6.0" diff --git a/contracts/tg4-group/.cargo/config b/contracts/tg4-group/.cargo/config new file mode 100644 index 00000000..7d1a066c --- /dev/null +++ b/contracts/tg4-group/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/tg4-group/Cargo.toml b/contracts/tg4-group/Cargo.toml new file mode 100644 index 00000000..9473b97c --- /dev/null +++ b/contracts/tg4-group/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "tg4-group" +version = "0.6.0" +authors = ["Mauro Lacy "] +edition = "2018" +description = "Simple tg4 implementation of group membership controlled by admin" +license = "Apache-2.0" +repository = "https://github.com/confio/poe-contracts" +homepage = "https://cosmwasm.com" +documentation = "https://docs.cosmwasm.com" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "artifacts/*", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[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] +cw-utils = "0.11.1" +cw2 = "0.11.1" +tg-bindings = { version = "0.6.0", path = "../../packages/bindings" } +tg-utils = { version = "0.6.0", path = "../../packages/utils" } +tg4 = { version = "0.6.0", path = "../../packages/tg4" } +cw-controllers = "0.11.1" +cw-storage-plus = "0.11.1" +cosmwasm-std = { version = "1.0.0-beta5" } +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } +thiserror = { version = "1.0.23" } + +[dev-dependencies] +cosmwasm-schema = { version = "1.0.0-beta5" } diff --git a/contracts/tg4-group/NOTICE b/contracts/tg4-group/NOTICE new file mode 100644 index 00000000..05e27a88 --- /dev/null +++ b/contracts/tg4-group/NOTICE @@ -0,0 +1,14 @@ +Tg4_group +Copyright (C) 2022 Confio Gmbh + +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. diff --git a/contracts/tg4-group/README.md b/contracts/tg4-group/README.md new file mode 100644 index 00000000..c3f0e219 --- /dev/null +++ b/contracts/tg4-group/README.md @@ -0,0 +1,51 @@ +# TG4 Group + +This is a basic implementation of the [tg4 spec](../../packages/tg4/README.md). +It fulfills all elements of the spec, including the raw query lookups, +and it designed to be used as a backing storage for +[cw3 compliant contracts](../../packages/cw3/README.md). + +It stores a set of members along with an admin, and allows the admin to +update the state. Raw queries (intended for cross-contract queries) +can check a given member address and the total points. Smart queries (designed +for client API) can do the same, and also query the admin address as well as +paginate over all members. + +## Init + +To create it, you must pass in a list of members, as well as an optional +`admin`, if you wish it to be mutable. + +```rust +pub struct InitMsg { + pub admin: Option, + pub members: Vec, +} + +pub struct Member { + pub addr: HumanAddr, + pub points: u64, +} +``` + +Members are defined by an address and a number of points. This is transformed +and stored under their `CanonicalAddr`, in a format defined in +[tg4 raw queries](../../packages/tg4/README.md#raw). + +Note that 0 *is an allowed number of points*. This doesn't give any voting rights, but +it does define this address is part of the group. This could be used in +e.g. a KYC whitelist to say they are allowed, but cannot participate in +decision-making. + +## Messages + +Basic update messages, queries, and hooks are defined by the +[tg4 spec](../../packages/tg4/README.md). Please refer to it for more info. + +`tg4-group` adds one message to control the group membership: + +`UpdateMembers{add, remove}` - takes a membership diff and adds/updates the +members, as well as removing any provided addresses. If an address is on both +lists, it will be removed. If it appears multiple times in `add`, only the +last occurrence will be used. + diff --git a/contracts/tg4-group/examples/schema.rs b/contracts/tg4-group/examples/schema.rs new file mode 100644 index 00000000..f5ed6fe2 --- /dev/null +++ b/contracts/tg4-group/examples/schema.rs @@ -0,0 +1,22 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, schema_for}; + +pub use tg4::{AdminResponse, MemberListResponse, MemberResponse, TotalPointsResponse}; +pub use tg4_group::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_with_title(&schema_for!(InstantiateMsg), &out_dir, "InstantiateMsg"); + export_schema_with_title(&schema_for!(ExecuteMsg), &out_dir, "ExecuteMsg"); + export_schema_with_title(&schema_for!(QueryMsg), &out_dir, "QueryMsg"); + export_schema(&schema_for!(AdminResponse), &out_dir); + export_schema(&schema_for!(MemberListResponse), &out_dir); + export_schema(&schema_for!(MemberResponse), &out_dir); + export_schema(&schema_for!(TotalPointsResponse), &out_dir); +} diff --git a/contracts/tg4-group/src/contract.rs b/contracts/tg4-group/src/contract.rs new file mode 100644 index 00000000..360cbfa2 --- /dev/null +++ b/contracts/tg4-group/src/contract.rs @@ -0,0 +1,606 @@ +use crate::ContractError::Unauthorized; +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + attr, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Order, StdResult, +}; +use cw2::set_contract_version; +use cw_storage_plus::Bound; +use cw_utils::maybe_addr; +use tg4::{ + HooksResponse, Member, MemberChangedHookMsg, MemberDiff, MemberListResponse, MemberResponse, + TotalPointsResponse, +}; +use tg_bindings::TgradeMsg; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{ADMIN, HOOKS, MEMBERS, TOTAL}; + +pub type Response = cosmwasm_std::Response; +pub type SubMsg = cosmwasm_std::SubMsg; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:tg4-group"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// Note, you can use StdResult in some functions where you do not +// make use of the custom errors +#[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)?; + create(deps, msg.admin, msg.members, env.block.height)?; + Ok(Response::default()) +} + +// create is the instantiation logic with set_contract_version removed so it can more +// easily be imported in other contracts +pub fn create( + mut deps: DepsMut, + admin: Option, + members: Vec, + height: u64, +) -> Result<(), ContractError> { + let admin_addr = admin + .map(|admin| deps.api.addr_validate(&admin)) + .transpose()?; + ADMIN.set(deps.branch(), admin_addr)?; + + let mut total = 0u64; + for member in members.into_iter() { + total += member.points; + let member_addr = deps.api.addr_validate(&member.addr)?; + MEMBERS.save(deps.storage, &member_addr, &member.points, height)?; + } + TOTAL.save(deps.storage, &total)?; + + Ok(()) +} + +// And declare a custom Error variant for the ones where you will want to make use of it +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + let api = deps.api; + match msg { + ExecuteMsg::UpdateAdmin { admin } => Ok(ADMIN.execute_update_admin( + deps, + info, + admin.map(|admin| api.addr_validate(&admin)).transpose()?, + )?), + ExecuteMsg::UpdateMembers { add, remove } => { + execute_update_members(deps, env, info, add, remove) + } + ExecuteMsg::AddHook { addr } => execute_add_hook(deps, info, addr), + ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, info, addr), + } +} + +pub fn execute_update_members( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + add: Vec, + remove: Vec, +) -> Result { + let attributes = vec![ + attr("action", "update_members"), + attr("added", add.len().to_string()), + attr("removed", remove.len().to_string()), + attr("sender", &info.sender), + ]; + + // make the local update + let diff = update_members(deps.branch(), env.block.height, info.sender, add, remove)?; + // call all registered hooks + let messages = HOOKS.prepare_hooks(deps.storage, |h| { + diff.clone().into_cosmos_msg(h).map(SubMsg::new) + })?; + Ok(Response::new() + .add_submessages(messages) + .add_attributes(attributes)) +} + +pub fn execute_add_hook( + deps: DepsMut, + info: MessageInfo, + hook: String, +) -> Result { + // Same as cw4-group guard: being admin + if !ADMIN.is_admin(deps.as_ref(), &info.sender)? { + return Err(Unauthorized {}); + } + + // add the hook + HOOKS.add_hook(deps.storage, deps.api.addr_validate(&hook)?)?; + + // response + let res = Response::new() + .add_attribute("action", "add_hook") + .add_attribute("hook", hook) + .add_attribute("sender", info.sender); + Ok(res) +} + +pub fn execute_remove_hook( + deps: DepsMut, + info: MessageInfo, + hook: String, +) -> Result { + // custom guard: self-removal OR being admin + let hook_addr = deps.api.addr_validate(&hook)?; + if info.sender != hook_addr && !ADMIN.is_admin(deps.as_ref(), &info.sender)? { + // return Err(ContractError::Unauthorized( + // "Hook address is not same as sender's or sender is not an admin".to_owned(), + // )); + return Err(ContractError::Unauthorized {}); + } + + // remove the hook + HOOKS.remove_hook(deps.storage, hook_addr)?; + + // response + let resp = Response::new() + .add_attribute("action", "remove_hook") + .add_attribute("hook", hook) + .add_attribute("sender", info.sender); + Ok(resp) +} + +// the logic from execute_update_members extracted for easier import +pub fn update_members( + deps: DepsMut, + height: u64, + sender: Addr, + to_add: Vec, + to_remove: Vec, +) -> Result { + ADMIN.assert_admin(deps.as_ref(), &sender)?; + + let mut total = TOTAL.load(deps.storage)?; + let mut diffs: Vec = vec![]; + + // add all new members and update total + for add in to_add.into_iter() { + let add_addr = deps.api.addr_validate(&add.addr)?; + MEMBERS.update(deps.storage, &add_addr, height, |old| -> StdResult<_> { + total -= old.unwrap_or_default(); + total += add.points; + diffs.push(MemberDiff::new(add.addr, old, Some(add.points))); + Ok(add.points) + })?; + } + + for remove in to_remove.into_iter() { + let remove_addr = deps.api.addr_validate(&remove)?; + let old = MEMBERS.may_load(deps.storage, &remove_addr)?; + // Only process this if they were actually in the list before + if let Some(points) = old { + diffs.push(MemberDiff::new(remove, Some(points), None)); + total -= points; + MEMBERS.remove(deps.storage, &remove_addr, height)?; + } + } + + TOTAL.save(deps.storage, &total)?; + Ok(MemberChangedHookMsg { diffs }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Member { + addr, + at_height: height, + } => to_binary(&query_member(deps, addr, height)?), + QueryMsg::ListMembers { start_after, limit } => { + to_binary(&list_members(deps, start_after, limit)?) + } + QueryMsg::TotalPoints {} => to_binary(&query_total_points(deps)?), + QueryMsg::Admin {} => to_binary(&ADMIN.query_admin(deps)?), + QueryMsg::Hooks {} => { + let hooks = HOOKS.list_hooks(deps.storage)?; + to_binary(&HooksResponse { hooks }) + } + } +} + +fn query_total_points(deps: Deps) -> StdResult { + let points = TOTAL.load(deps.storage)?; + Ok(TotalPointsResponse { points }) +} + +fn query_member(deps: Deps, addr: String, height: Option) -> StdResult { + let addr = deps.api.addr_validate(&addr)?; + let points = match height { + Some(h) => MEMBERS.may_load_at_height(deps.storage, &addr, h), + None => MEMBERS.may_load(deps.storage, &addr), + }?; + Ok(MemberResponse { points }) +} + +// settings for pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +fn list_members( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let addr = maybe_addr(deps.api, start_after)?; + let start = addr.map(|addr| Bound::exclusive(addr.as_ref())); + + let members = MEMBERS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + item.map(|(addr, points)| Member { + addr: addr.into(), + points, + }) + }) + .collect::>()?; + + Ok(MemberListResponse { members }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{from_slice, Api, OwnedDeps, Querier, Storage}; + use cw_controllers::AdminError; + use tg4::{member_key, TOTAL_KEY}; + use tg_utils::HookError; + + const INIT_ADMIN: &str = "juan"; + const USER1: &str = "somebody"; + const USER2: &str = "else"; + const USER3: &str = "funny"; + + fn do_instantiate(deps: DepsMut) { + let msg = InstantiateMsg { + admin: Some(INIT_ADMIN.into()), + members: vec![ + Member { + addr: USER1.into(), + points: 11, + }, + Member { + addr: USER2.into(), + points: 6, + }, + ], + }; + let info = mock_info("creator", &[]); + instantiate(deps, mock_env(), info, msg).unwrap(); + } + + #[test] + fn proper_instantiation() { + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // it worked, let's query the state + let res = ADMIN.query_admin(deps.as_ref()).unwrap(); + assert_eq!(Some(INIT_ADMIN.into()), res.admin); + + let res = query_total_points(deps.as_ref()).unwrap(); + assert_eq!(17, res.points); + } + + #[test] + fn try_member_queries() { + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + let member1 = query_member(deps.as_ref(), USER1.into(), None).unwrap(); + assert_eq!(member1.points, Some(11)); + + let member2 = query_member(deps.as_ref(), USER2.into(), None).unwrap(); + assert_eq!(member2.points, Some(6)); + + let member3 = query_member(deps.as_ref(), USER3.into(), None).unwrap(); + assert_eq!(member3.points, None); + + let members = list_members(deps.as_ref(), None, None).unwrap(); + assert_eq!(members.members.len(), 2); + // TODO: assert the set is proper + } + + fn assert_users( + deps: &OwnedDeps, + user1_points: Option, + user2_points: Option, + user3_points: Option, + height: Option, + ) { + let member1 = query_member(deps.as_ref(), USER1.into(), height).unwrap(); + assert_eq!(member1.points, user1_points); + + let member2 = query_member(deps.as_ref(), USER2.into(), height).unwrap(); + assert_eq!(member2.points, user2_points); + + let member3 = query_member(deps.as_ref(), USER3.into(), height).unwrap(); + assert_eq!(member3.points, user3_points); + + // this is only valid if we are not doing a historical query + if height.is_none() { + // compute expected metrics + let points = vec![user1_points, user2_points, user3_points]; + let sum: u64 = points.iter().map(|x| x.unwrap_or_default()).sum(); + let count = points.iter().filter(|x| x.is_some()).count(); + + // TODO: more detailed compare? + let members = list_members(deps.as_ref(), None, None).unwrap(); + assert_eq!(count, members.members.len()); + + let total = query_total_points(deps.as_ref()).unwrap(); + assert_eq!(sum, total.points); // 17 - 11 + 15 = 21 + } + } + + #[test] + fn add_new_remove_old_member() { + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // add a new one and remove existing one + let add = vec![Member { + addr: USER3.into(), + points: 15, + }]; + let remove = vec![USER1.into()]; + + // non-admin cannot update + let height = mock_env().block.height; + let err = update_members( + deps.as_mut(), + height + 5, + Addr::unchecked(USER1), + add.clone(), + remove.clone(), + ) + .unwrap_err(); + assert_eq!(err, AdminError::NotAdmin {}.into()); + + // Test the values from instantiate + assert_users(&deps, Some(11), Some(6), None, None); + // Note all values were set at height, the beginning of that block was all None + assert_users(&deps, None, None, None, Some(height)); + // This will get us the values at the start of the block after instantiate (expected initial values) + assert_users(&deps, Some(11), Some(6), None, Some(height + 1)); + + // admin updates properly + update_members( + deps.as_mut(), + height + 10, + Addr::unchecked(INIT_ADMIN), + add, + remove, + ) + .unwrap(); + + // updated properly + assert_users(&deps, None, Some(6), Some(15), None); + + // snapshot still shows old value + assert_users(&deps, Some(11), Some(6), None, Some(height + 1)); + } + + #[test] + fn add_old_remove_new_member() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // add a new one and remove existing one + let add = vec![Member { + addr: USER1.into(), + points: 4, + }]; + let remove = vec![USER3.into()]; + + // admin updates properly + let height = mock_env().block.height; + update_members( + deps.as_mut(), + height, + Addr::unchecked(INIT_ADMIN), + add, + remove, + ) + .unwrap(); + assert_users(&deps, Some(4), Some(6), None, None); + } + + #[test] + fn add_and_remove_same_member() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // USER1 is updated and remove in the same call, we should remove this an add member3 + let add = vec![ + Member { + addr: USER1.into(), + points: 20, + }, + Member { + addr: USER3.into(), + points: 5, + }, + ]; + let remove = vec![USER1.into()]; + + // admin updates properly + let height = mock_env().block.height; + update_members( + deps.as_mut(), + height, + Addr::unchecked(INIT_ADMIN), + add, + remove, + ) + .unwrap(); + assert_users(&deps, None, Some(6), Some(5), None); + } + + #[test] + fn add_remove_hooks() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + let hooks = HOOKS.list_hooks(&deps.storage).unwrap(); + assert!(hooks.is_empty()); + + let contract1 = String::from("hook1"); + let contract2 = String::from("hook2"); + + let add_msg = ExecuteMsg::AddHook { + addr: contract1.clone(), + }; + + // non-admin cannot add hook + let user_info = mock_info(USER1, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + user_info.clone(), + add_msg.clone(), + ) + .unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + + // admin can add it, and it appears in the query + let admin_info = mock_info(INIT_ADMIN, &[]); + let _ = execute( + deps.as_mut(), + mock_env(), + admin_info.clone(), + add_msg.clone(), + ) + .unwrap(); + let hooks = HOOKS.list_hooks(&deps.storage).unwrap(); + assert_eq!(hooks, vec![contract1.clone()]); + + // cannot remove a non-registered contract + let remove_msg = ExecuteMsg::RemoveHook { + addr: contract2.clone(), + }; + let err = execute(deps.as_mut(), mock_env(), admin_info.clone(), remove_msg).unwrap_err(); + assert_eq!(err, HookError::HookNotRegistered {}.into()); + + // add second contract + let add_msg2 = ExecuteMsg::AddHook { + addr: contract2.clone(), + }; + let _ = execute(deps.as_mut(), mock_env(), admin_info.clone(), add_msg2).unwrap(); + let hooks = HOOKS.list_hooks(&deps.storage).unwrap(); + assert_eq!(hooks, vec![contract1.clone(), contract2.clone()]); + + // cannot re-add an existing contract + let err = execute(deps.as_mut(), mock_env(), admin_info.clone(), add_msg).unwrap_err(); + assert_eq!(err, HookError::HookAlreadyRegistered {}.into()); + + // non-admin cannot remove + let remove_msg = ExecuteMsg::RemoveHook { addr: contract1 }; + let err = execute(deps.as_mut(), mock_env(), user_info, remove_msg.clone()).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); + + // remove the original + let _ = execute(deps.as_mut(), mock_env(), admin_info, remove_msg).unwrap(); + let hooks = HOOKS.list_hooks(&deps.storage).unwrap(); + assert_eq!(hooks, vec![contract2]); + } + + #[test] + fn hooks_fire() { + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + let hooks = HOOKS.list_hooks(&deps.storage).unwrap(); + assert!(hooks.is_empty()); + + let contract1 = String::from("hook1"); + let contract2 = String::from("hook2"); + + // register 2 hooks + let admin_info = mock_info(INIT_ADMIN, &[]); + let add_msg = ExecuteMsg::AddHook { + addr: contract1.clone(), + }; + let add_msg2 = ExecuteMsg::AddHook { + addr: contract2.clone(), + }; + for msg in vec![add_msg, add_msg2] { + let _ = execute(deps.as_mut(), mock_env(), admin_info.clone(), msg).unwrap(); + } + + // make some changes - add 3, remove 2, and update 1 + // USER1 is updated and remove in the same call, we should remove this an add member3 + let add = vec![ + Member { + addr: USER1.into(), + points: 20, + }, + Member { + addr: USER3.into(), + points: 5, + }, + ]; + let remove = vec![USER2.into()]; + let msg = ExecuteMsg::UpdateMembers { remove, add }; + + // admin updates properly + assert_users(&deps, Some(11), Some(6), None, None); + let res = execute(deps.as_mut(), mock_env(), admin_info, msg).unwrap(); + assert_users(&deps, Some(20), None, Some(5), None); + + // ensure 2 messages for the 2 hooks + assert_eq!(res.messages.len(), 2); + // same order as in the message (adds first, then remove) + let diffs = vec![ + MemberDiff::new(USER1, Some(11), Some(20)), + MemberDiff::new(USER3, None, Some(5)), + MemberDiff::new(USER2, Some(6), None), + ]; + let hook_msg = MemberChangedHookMsg { diffs }; + let msg1 = SubMsg::new(hook_msg.clone().into_cosmos_msg(contract1).unwrap()); + let msg2 = SubMsg::new(hook_msg.into_cosmos_msg(contract2).unwrap()); + assert_eq!(res.messages, vec![msg1, msg2]); + } + + #[test] + fn raw_queries_work() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // get total from raw key + let total_raw = deps.storage.get(TOTAL_KEY.as_bytes()).unwrap(); + let total: u64 = from_slice(&total_raw).unwrap(); + assert_eq!(17, total); + + // get member votes from raw key + let member2_raw = deps.storage.get(&member_key(USER2)).unwrap(); + let member2: u64 = from_slice(&member2_raw).unwrap(); + assert_eq!(6, member2); + + // and execute misses + let member3_raw = deps.storage.get(&member_key(USER3)); + assert_eq!(None, member3_raw); + } +} diff --git a/contracts/tg4-group/src/error.rs b/contracts/tg4-group/src/error.rs new file mode 100644 index 00000000..cb1812e2 --- /dev/null +++ b/contracts/tg4-group/src/error.rs @@ -0,0 +1,20 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +use cw_controllers::AdminError; +use tg_utils::HookError; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + Hook(#[from] HookError), + + #[error("{0}")] + Admin(#[from] AdminError), + + #[error("Unauthorized")] + Unauthorized {}, +} diff --git a/contracts/tg4-group/src/helpers.rs b/contracts/tg4-group/src/helpers.rs new file mode 100644 index 00000000..20e8332a --- /dev/null +++ b/contracts/tg4-group/src/helpers.rs @@ -0,0 +1,43 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::ops::Deref; + +use cosmwasm_std::{to_binary, Addr, CosmosMsg, StdResult, WasmMsg}; +use tg4::{Member, Tg4Contract}; + +use crate::msg::ExecuteMsg; + +/// Tg4GroupContract is a wrapper around Tg4Contract that provides a lot of helpers +/// for working with tg4-group contracts. +/// +/// It extends Tg4Contract to add the extra calls from tg4-group. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Tg4GroupContract(pub Tg4Contract); + +impl Deref for Tg4GroupContract { + type Target = Tg4Contract; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Tg4GroupContract { + pub fn new(addr: Addr) -> Self { + Tg4GroupContract(Tg4Contract(addr)) + } + + fn encode_msg(&self, msg: ExecuteMsg) -> StdResult { + Ok(WasmMsg::Execute { + contract_addr: self.addr().into(), + msg: to_binary(&msg)?, + funds: vec![], + } + .into()) + } + + pub fn update_members(&self, remove: Vec, add: Vec) -> StdResult { + let msg = ExecuteMsg::UpdateMembers { remove, add }; + self.encode_msg(msg) + } +} diff --git a/contracts/tg4-group/src/lib.rs b/contracts/tg4-group/src/lib.rs new file mode 100644 index 00000000..98208b78 --- /dev/null +++ b/contracts/tg4-group/src/lib.rs @@ -0,0 +1,7 @@ +pub mod contract; +pub mod error; +pub mod helpers; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/tg4-group/src/msg.rs b/contracts/tg4-group/src/msg.rs new file mode 100644 index 00000000..9bb2877b --- /dev/null +++ b/contracts/tg4-group/src/msg.rs @@ -0,0 +1,51 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use tg4::Member; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub struct InstantiateMsg { + /// The admin is the only account that can update the group state. + /// Omit it to make the group immutable. + pub admin: Option, + pub members: Vec, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + /// Change the admin + UpdateAdmin { admin: Option }, + /// apply a diff to the existing members. + /// remove is applied after add, so if an address is in both, it is removed + UpdateMembers { + remove: Vec, + add: Vec, + }, + /// Add a new hook to be informed of all membership changes. Must be called by Admin + AddHook { addr: String }, + /// Remove a hook. Must be called by Admin + RemoveHook { addr: String }, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// Return AdminResponse + Admin {}, + /// Return TotalPointsResponse + TotalPoints {}, + /// Returns MembersListResponse + ListMembers { + start_after: Option, + limit: Option, + }, + /// Returns MemberResponse + Member { + addr: String, + at_height: Option, + }, + /// Shows all registered hooks. Returns HooksResponse. + Hooks {}, +} diff --git a/contracts/tg4-group/src/state.rs b/contracts/tg4-group/src/state.rs new file mode 100644 index 00000000..577178bc --- /dev/null +++ b/contracts/tg4-group/src/state.rs @@ -0,0 +1,17 @@ +use cosmwasm_std::Addr; +use cw_controllers::Admin; +use cw_storage_plus::{Item, SnapshotMap, Strategy}; +use tg4::TOTAL_KEY; +use tg_utils::Hooks; + +pub const ADMIN: Admin = Admin::new("admin"); +pub const HOOKS: Hooks = Hooks::new("tg4-hooks"); + +pub const TOTAL: Item = Item::new(TOTAL_KEY); + +pub const MEMBERS: SnapshotMap<&Addr, u64> = SnapshotMap::new( + tg4::MEMBERS_KEY, + tg4::MEMBERS_CHECKPOINTS, + tg4::MEMBERS_CHANGELOG, + Strategy::EveryBlock, +); diff --git a/scripts/publish.sh b/scripts/publish.sh index e9c090c2..2dbe694f 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -5,7 +5,7 @@ command -v shellcheck >/dev/null && shellcheck "$0" # These are imported by other packages - wait 30 seconds between each as they have linear dependencies BASE_CRATES="packages/bindings packages/bindings-test packages/tg4 packages/utils contracts/tg4-engagement contracts/tg4-stake contracts/tg4-mixer packages/tg3 packages/voting-contract" -ALL_CRATES="packages/test-utils contracts/tgrade-community-pool contracts/tgrade-validator-voting contracts/tgrade-valset" +ALL_CRATES="packages/test-utils contracts/tgrade-community-pool contracts/tgrade-validator-voting contracts/tgrade-valset contracts/tg4-group" SLEEP_TIME=30