diff --git a/contracts/cw3-flex-multisig/README.md b/contracts/cw3-flex-multisig/README.md index 9f71e2863..716e5d277 100644 --- a/contracts/cw3-flex-multisig/README.md +++ b/contracts/cw3-flex-multisig/README.md @@ -29,10 +29,16 @@ with the desired member set. For now, this only is supported by (TODO). If you create a `cw4-group` contract and want a multisig to be able -to modify it's own group, set the admin as your personal key, then init the -multisig pointing to the group, then modify the admin of the group to point -back to the multisig. This is the current practice to create such circular -dependencies (TODO: document better). +to modify it's own group, do the following in multiple transactions: + + * init cw4-group, with your personal key as admin + * init a multisig pointing to the group + * TODO: `AddHook{multisig}` on the group contract + * `UpdateAdmin{multisig}` on the group contract + +This is the current practice to create such circular dependencies, +and depends on an external driver (hard to impossible to script such a +self-deploying contract on-chain). (TODO: document better). When creating the multisig, you must set the required weight to pass a vote as well as the max/default voting period. (TODO: allow more threshold types) diff --git a/contracts/cw4-group/schema/handle_msg.json b/contracts/cw4-group/schema/handle_msg.json index a3b4c7243..bb66c7771 100644 --- a/contracts/cw4-group/schema/handle_msg.json +++ b/contracts/cw4-group/schema/handle_msg.json @@ -55,6 +55,46 @@ } } } + }, + { + "description": "Add a new hook to be informed of all membership changes. Must be called by Admin", + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + }, + { + "description": "Remove a hook. Must be called by Admin", + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } } ], "definitions": { diff --git a/contracts/cw4-group/schema/query_msg.json b/contracts/cw4-group/schema/query_msg.json index 32884bd9b..94c694c33 100644 --- a/contracts/cw4-group/schema/query_msg.json +++ b/contracts/cw4-group/schema/query_msg.json @@ -77,6 +77,18 @@ } } } + }, + { + "description": "Shows all registered hooks. Returns HooksResponse.", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "object" + } + } } ], "definitions": { diff --git a/contracts/cw4-group/src/contract.rs b/contracts/cw4-group/src/contract.rs index 264d6bd35..3d4739058 100644 --- a/contracts/cw4-group/src/contract.rs +++ b/contracts/cw4-group/src/contract.rs @@ -1,15 +1,18 @@ use cosmwasm_std::{ - to_binary, Api, Binary, CanonicalAddr, Deps, DepsMut, Env, HandleResponse, HumanAddr, + to_binary, Api, Binary, CanonicalAddr, Context, Deps, DepsMut, Env, HandleResponse, HumanAddr, InitResponse, MessageInfo, Order, StdResult, }; use cw0::maybe_canonical; use cw2::set_contract_version; -use cw4::{AdminResponse, Member, MemberListResponse, MemberResponse, TotalWeightResponse}; +use cw4::{ + AdminResponse, HooksResponse, Member, MemberChangedHookMsg, MemberDiff, MemberListResponse, + MemberResponse, TotalWeightResponse, +}; use cw_storage_plus::Bound; use crate::error::ContractError; use crate::msg::{HandleMsg, InitMsg, QueryMsg}; -use crate::state::{ADMIN, MEMBERS, TOTAL}; +use crate::state::{ADMIN, HOOKS, MEMBERS, TOTAL}; // version info for migration info const CONTRACT_NAME: &str = "crates.io:cw4-group"; @@ -50,6 +53,8 @@ pub fn handle( match msg { HandleMsg::UpdateAdmin { admin } => handle_update_admin(deps, info, admin), HandleMsg::UpdateMembers { add, remove } => handle_update_members(deps, info, add, remove), + HandleMsg::AddHook { addr } => handle_add_hook(deps, info, addr), + HandleMsg::RemoveHook { addr } => handle_remove_hook(deps, info, addr), } } @@ -77,13 +82,20 @@ pub fn update_admin( } pub fn handle_update_members( - deps: DepsMut, + mut deps: DepsMut, info: MessageInfo, add: Vec, remove: Vec, ) -> Result { - update_members(deps, info.sender, add, remove)?; - Ok(HandleResponse::default()) + // make the local update + let diff = update_members(deps.branch(), info.sender, add, remove)?; + // call all registered hooks + let mut ctx = Context::new(); + for h in HOOKS.may_load(deps.storage)?.unwrap_or_default() { + let msg = diff.clone().into_cosmos_msg(h)?; + ctx.add_message(msg); + } + Ok(ctx.into()) } // the logic from handle_update_admin extracted for easier import @@ -92,11 +104,12 @@ pub fn update_members( sender: HumanAddr, to_add: Vec, to_remove: Vec, -) -> Result<(), ContractError> { +) -> Result { let admin = ADMIN.load(deps.storage)?; assert_admin(deps.api, sender, admin)?; 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() { @@ -104,19 +117,24 @@ pub fn update_members( MEMBERS.update(deps.storage, &raw, |old| -> StdResult<_> { total -= old.unwrap_or_default(); total += add.weight; + diffs.push(MemberDiff::new(add.addr, old, Some(add.weight))); Ok(add.weight) })?; } for remove in to_remove.into_iter() { let raw = deps.api.canonical_address(&remove)?; - total -= MEMBERS.may_load(deps.storage, &raw)?.unwrap_or_default(); - MEMBERS.remove(deps.storage, &raw); + let old = MEMBERS.may_load(deps.storage, &raw)?; + // Only process this if they were actually in the list before + if let Some(weight) = old { + diffs.push(MemberDiff::new(remove, Some(weight), None)); + total -= weight; + MEMBERS.remove(deps.storage, &raw); + } } TOTAL.save(deps.storage, &total)?; - - Ok(()) + Ok(MemberChangedHookMsg { diffs }) } fn assert_admin( @@ -135,6 +153,42 @@ fn assert_admin( } } +pub fn handle_add_hook( + deps: DepsMut, + info: MessageInfo, + addr: HumanAddr, +) -> Result { + let admin = ADMIN.load(deps.storage)?; + assert_admin(deps.api, info.sender, admin)?; + + let mut hooks = HOOKS.may_load(deps.storage)?.unwrap_or_default(); + if !hooks.iter().any(|h| h == &addr) { + hooks.push(addr); + } else { + return Err(ContractError::HookAlreadyRegistered {}); + } + HOOKS.save(deps.storage, &hooks)?; + Ok(HandleResponse::default()) +} + +pub fn handle_remove_hook( + deps: DepsMut, + info: MessageInfo, + addr: HumanAddr, +) -> Result { + let admin = ADMIN.load(deps.storage)?; + assert_admin(deps.api, info.sender, admin)?; + + let mut hooks = HOOKS.load(deps.storage)?; + if let Some(p) = hooks.iter().position(|x| x == &addr) { + hooks.remove(p); + } else { + return Err(ContractError::HookNotRegistered {}); + } + HOOKS.save(deps.storage, &hooks)?; + Ok(HandleResponse::default()) +} + pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { match msg { QueryMsg::Member { addr } => to_binary(&query_member(deps, addr)?), @@ -143,6 +197,7 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { } QueryMsg::Admin {} => to_binary(&query_admin(deps)?), QueryMsg::TotalWeight {} => to_binary(&query_total_weight(deps)?), + QueryMsg::Hooks {} => to_binary(&query_hooks(deps)?), } } @@ -152,6 +207,11 @@ fn query_admin(deps: Deps) -> StdResult { Ok(AdminResponse { admin }) } +fn query_hooks(deps: Deps) -> StdResult { + let hooks = HOOKS.may_load(deps.storage)?.unwrap_or_default(); + Ok(HooksResponse { hooks }) +} + fn query_total_weight(deps: Deps) -> StdResult { let weight = TOTAL.load(deps.storage)?; Ok(TotalWeightResponse { weight }) @@ -362,7 +422,7 @@ mod tests { let mut deps = mock_dependencies(&[]); do_init(deps.as_mut()); - // USER1 is updated and remove in the same line, we should remove this an add member3 + // USER1 is updated and remove in the same call, we should remove this an add member3 let add = vec![ Member { addr: USER1.into(), @@ -380,6 +440,170 @@ mod tests { assert_users(&deps, None, Some(6), Some(5)); } + #[test] + fn add_remove_hooks() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(&[]); + do_init(deps.as_mut()); + + let hooks = query_hooks(deps.as_ref()).unwrap(); + assert!(hooks.hooks.is_empty()); + + let contract1 = HumanAddr::from("hook1"); + let contract2 = HumanAddr::from("hook2"); + + let add_msg = HandleMsg::AddHook { + addr: contract1.clone(), + }; + + // non-admin cannot add hook + let user_info = mock_info(USER1, &[]); + let err = handle( + deps.as_mut(), + mock_env(), + user_info.clone(), + add_msg.clone(), + ) + .unwrap_err(); + match err { + ContractError::Unauthorized {} => {} + e => panic!("Unexpected error: {}", e), + } + + // admin can add it, and it appears in the query + let admin_info = mock_info(ADMIN, &[]); + let _ = handle( + deps.as_mut(), + mock_env(), + admin_info.clone(), + add_msg.clone(), + ) + .unwrap(); + let hooks = query_hooks(deps.as_ref()).unwrap(); + assert_eq!(hooks.hooks, vec![contract1.clone()]); + + // cannot remove a non-registered contract + let remove_msg = HandleMsg::RemoveHook { + addr: contract2.clone(), + }; + let err = handle( + deps.as_mut(), + mock_env(), + admin_info.clone(), + remove_msg.clone(), + ) + .unwrap_err(); + match err { + ContractError::HookNotRegistered {} => {} + e => panic!("Unexpected error: {}", e), + } + + // add second contract + let add_msg2 = HandleMsg::AddHook { + addr: contract2.clone(), + }; + let _ = handle(deps.as_mut(), mock_env(), admin_info.clone(), add_msg2).unwrap(); + let hooks = query_hooks(deps.as_ref()).unwrap(); + assert_eq!(hooks.hooks, vec![contract1.clone(), contract2.clone()]); + + // cannot re-add an existing contract + let err = handle( + deps.as_mut(), + mock_env(), + admin_info.clone(), + add_msg.clone(), + ) + .unwrap_err(); + match err { + ContractError::HookAlreadyRegistered {} => {} + e => panic!("Unexpected error: {}", e), + } + + // non-admin cannot remove + let remove_msg = HandleMsg::RemoveHook { + addr: contract1.clone(), + }; + let err = handle( + deps.as_mut(), + mock_env(), + user_info.clone(), + remove_msg.clone(), + ) + .unwrap_err(); + match err { + ContractError::Unauthorized {} => {} + e => panic!("Unexpected error: {}", e), + } + + // remove the original + let _ = handle( + deps.as_mut(), + mock_env(), + admin_info.clone(), + remove_msg.clone(), + ) + .unwrap(); + let hooks = query_hooks(deps.as_ref()).unwrap(); + assert_eq!(hooks.hooks, vec![contract2.clone()]); + } + + #[test] + fn hooks_fire() { + let mut deps = mock_dependencies(&[]); + do_init(deps.as_mut()); + + let hooks = query_hooks(deps.as_ref()).unwrap(); + assert!(hooks.hooks.is_empty()); + + let contract1 = HumanAddr::from("hook1"); + let contract2 = HumanAddr::from("hook2"); + + // register 2 hooks + let admin_info = mock_info(ADMIN, &[]); + let add_msg = HandleMsg::AddHook { + addr: contract1.clone(), + }; + let add_msg2 = HandleMsg::AddHook { + addr: contract2.clone(), + }; + for msg in vec![add_msg, add_msg2] { + let _ = handle(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(), + weight: 20, + }, + Member { + addr: USER3.into(), + weight: 5, + }, + ]; + let remove = vec![USER2.into()]; + let msg = HandleMsg::UpdateMembers { remove, add }; + + // admin updates properly + assert_users(&deps, Some(11), Some(6), None); + let res = handle(deps.as_mut(), mock_env(), admin_info.clone(), msg).unwrap(); + assert_users(&deps, Some(20), None, Some(5)); + + // 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 = hook_msg.clone().into_cosmos_msg(contract1).unwrap(); + let msg2 = 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 diff --git a/contracts/cw4-group/src/error.rs b/contracts/cw4-group/src/error.rs index 4a69d8ff2..d894dd319 100644 --- a/contracts/cw4-group/src/error.rs +++ b/contracts/cw4-group/src/error.rs @@ -8,6 +8,10 @@ pub enum ContractError { #[error("Unauthorized")] Unauthorized {}, - // Add any other custom errors you like here. - // Look at https://docs.rs/thiserror/1.0.21/thiserror/ for details. + + #[error("Given address already registered as a hook")] + HookAlreadyRegistered {}, + + #[error("Given address not registered as a hook")] + HookNotRegistered {}, } diff --git a/contracts/cw4-group/src/state.rs b/contracts/cw4-group/src/state.rs index 7cb9878a8..ab8c5bf09 100644 --- a/contracts/cw4-group/src/state.rs +++ b/contracts/cw4-group/src/state.rs @@ -1,7 +1,10 @@ -use cosmwasm_std::CanonicalAddr; +use cosmwasm_std::{CanonicalAddr, HumanAddr}; use cw4::{MEMBERS_KEY, TOTAL_KEY}; use cw_storage_plus::{Item, Map}; pub const ADMIN: Item> = Item::new(b"admin"); pub const TOTAL: Item = Item::new(TOTAL_KEY); pub const MEMBERS: Map<&[u8], u64> = Map::new(MEMBERS_KEY); +// store all hook addresses in one item. We cannot have many of them before the contract +// becomes unusable +pub const HOOKS: Item> = Item::new(b"hooks"); diff --git a/packages/cw4/README.md b/packages/cw4/README.md index ad4d77154..2aeb31500 100644 --- a/packages/cw4/README.md +++ b/packages/cw4/README.md @@ -1,7 +1,7 @@ # CW4 Spec: Group Members CW4 is a spec for storing group membership, which can be combined -with CW3 multisigs. The purpose is to store a set of actors/voters +with CW3 multisigs. The purpose is to store a set of members/voters that can be accessed to determine permissions in another section. Since this is often deployed as a contract pair, we expect this @@ -23,7 +23,7 @@ as part of another flow. Implementations should work with this setup, but may add extra `Option` fields for non-essential extensions to configure in the `init` phase. -There are two messages supported by a group contract: +There are four messages supported by a group contract: `UpdateAdmin{admin}` - changes (or clears) the admin for the contract @@ -31,13 +31,21 @@ There are two messages supported by a group contract: 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 occurance will be used. - -Only the `admin` may execute either of these function. Thus, by omitting an + +`AddHook{addr}` - adds a contract address to be called upon every + `UpdateMembers` call. This can only be called by the admin, and care must + be taken. A contract returning an error or running out of gas will + revert the membership change (see more in Hooks section below). + +`RemoveHook{addr}` - unregister a contract address that was previously set + by `AddHook`. + +Only the `admin` may execute any of these function. Thus, by omitting an `admin`, we end up with a similar functionality ad `cw3-fixed-multisig`. If we include one, it may often be desired to be a `cw3` contract that uses this group contract as a group. This leads to a bit of chicken-and-egg -problem, but we will cover how to instantiate that in `cw3-flexible-multisig` -when the contract is built (TODO). +problem, but we cover how to instantiate that in +[`cw3-flexible-multisig`](../../contracts/cw3-flexible-multisig/README.md#init). ## Queries @@ -63,7 +71,49 @@ in contract-contract calls. These use keys exported by `cw4` `TOTAL_KEY` - making a raw query with this key (`b"total"`) will return a JSON-encoded `u64` - `members_key()` - takes a `CanonicalAddr` and returns a key that can be +`members_key()` - takes a `CanonicalAddr` and returns a key that can be used for raw query (`"\x00\x07members" || addr`). This will return empty bytes if the member is not inside the group, otherwise a - JSON-encoded `u64` \ No newline at end of file + JSON-encoded `u64` + +## Hooks + +One special feature of the `cw4` contracts is they allow the admin to +register multiple hooks. These are special contracts that need to react +to changes in the group membership, and this allows them stay in sync. +Again, note this is a powerful ability and you should only set hooks +to contracts you fully trust, generally some contracts you deployed +alongside the group. + +If a contract is registered as a hook on a cw4 contract, then anytime +`UpdateMembers` is successfully executed, the hook will receive a `handle` +call with the following format: + +```json +{ + "member_changed_hook": { + "diffs": [ + { + "addr": "cosmos1y3x7q772u8s25c5zve949fhanrhvmtnu484l8z", + "old_weight": 20, + "new_weight": 24 + } + ] + } +} +``` + +See [hook.rs](./src/hook.rs) for full details. Note that this example +shows an update or an existing member. `old_weight` will +be missing if the address was added for the first time. And +`new_weight` will be missing if the address was removed. + +The receiving contract must be able to handle the `MemberChangedHookMsg` +and should only return an error if it wants to change the functionality +of the group contract (eg. a multisig that wants to prevent membership +changes while there is an open proposal). However, such cases are quite +rare and often point to fragile code. + +Note that the message sender will be the group contract that was updated. +Make sure you check this when handling, so external actors cannot +call this hook, only the trusted group. diff --git a/packages/cw4/examples/schema.rs b/packages/cw4/examples/schema.rs index 8907c3e0e..b6078bbbe 100644 --- a/packages/cw4/examples/schema.rs +++ b/packages/cw4/examples/schema.rs @@ -4,8 +4,8 @@ use std::fs::create_dir_all; use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; pub use cw4::{ - AdminResponse, Cw4HandleMsg, Cw4InitMsg, Cw4QueryMsg, Member, MemberListResponse, - MemberResponse, TotalWeightResponse, + AdminResponse, Cw4HandleMsg, Cw4InitMsg, Cw4QueryMsg, Member, MemberChangedHookMsg, + MemberListResponse, MemberResponse, TotalWeightResponse, }; fn main() { @@ -21,4 +21,5 @@ fn main() { export_schema(&schema_for!(MemberListResponse), &out_dir); export_schema(&schema_for!(MemberResponse), &out_dir); export_schema(&schema_for!(TotalWeightResponse), &out_dir); + export_schema(&schema_for!(MemberChangedHookMsg), &out_dir); } diff --git a/packages/cw4/schema/cw4_handle_msg.json b/packages/cw4/schema/cw4_handle_msg.json index 22d0d3830..ec3256791 100644 --- a/packages/cw4/schema/cw4_handle_msg.json +++ b/packages/cw4/schema/cw4_handle_msg.json @@ -55,6 +55,46 @@ } } } + }, + { + "description": "Add a new hook to be informed of all membership changes. Must be called by Admin", + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } + }, + { + "description": "Remove a hook. Must be called by Admin", + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + } + } + } + } } ], "definitions": { diff --git a/packages/cw4/schema/cw4_query_msg.json b/packages/cw4/schema/cw4_query_msg.json index 8e05ec1e5..46cb89e3c 100644 --- a/packages/cw4/schema/cw4_query_msg.json +++ b/packages/cw4/schema/cw4_query_msg.json @@ -77,6 +77,18 @@ } } } + }, + { + "description": "Shows all registered hooks. Returns HooksResponse.", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "object" + } + } } ], "definitions": { diff --git a/packages/cw4/schema/member_changed_hook_msg.json b/packages/cw4/schema/member_changed_hook_msg.json new file mode 100644 index 000000000..04cab509c --- /dev/null +++ b/packages/cw4/schema/member_changed_hook_msg.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MemberChangedHookMsg", + "description": "MemberChangedHookMsg should be de/serialized under `MemberChangedHook()` variant in a HandleMsg", + "type": "object", + "required": [ + "diffs" + ], + "properties": { + "diffs": { + "type": "array", + "items": { + "$ref": "#/definitions/MemberDiff" + } + } + }, + "definitions": { + "HumanAddr": { + "type": "string" + }, + "MemberDiff": { + "description": "MemberDiff shows the old and new states for a given cw4 member They cannot both be None. old = None, new = Some -> Insert old = Some, new = Some -> Update old = Some, new = None -> Delete", + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "$ref": "#/definitions/HumanAddr" + }, + "new_weight": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "old_weight": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + } + } + } +} diff --git a/packages/cw4/src/helpers.rs b/packages/cw4/src/helpers.rs index 991529fc7..e515b212b 100644 --- a/packages/cw4/src/helpers.rs +++ b/packages/cw4/src/helpers.rs @@ -7,6 +7,7 @@ use cosmwasm_std::{ }; use crate::msg::Cw4HandleMsg; +use crate::query::HooksResponse; use crate::{member_key, AdminResponse, Cw4QueryMsg, Member, MemberListResponse, TOTAL_KEY}; /// Cw4Contract is a wrapper around HumanAddr that provides a lot of helpers @@ -49,6 +50,16 @@ impl Cw4Contract { self.encode_msg(msg) } + pub fn add_hook(&self, addr: HumanAddr) -> StdResult { + let msg = Cw4HandleMsg::AddHook { addr }; + self.encode_msg(msg) + } + + pub fn remove_hook(&self, addr: HumanAddr) -> StdResult { + let msg = Cw4HandleMsg::AddHook { addr }; + self.encode_msg(msg) + } + fn encode_smart_query(&self, msg: Cw4QueryMsg) -> StdResult> { Ok(WasmQuery::Smart { contract_addr: self.addr(), @@ -72,6 +83,13 @@ impl Cw4Contract { Ok(res.admin) } + /// Show the hooks + pub fn hooks(&self, querier: &QuerierWrapper) -> StdResult> { + let query = self.encode_smart_query(Cw4QueryMsg::Hooks {})?; + let res: HooksResponse = querier.query(&query)?; + Ok(res.hooks) + } + /// Read the total weight pub fn total_weight(&self, querier: &QuerierWrapper) -> StdResult { let query = self.encode_raw_query(TOTAL_KEY)?; diff --git a/packages/cw4/src/hook.rs b/packages/cw4/src/hook.rs new file mode 100644 index 000000000..34ae558cb --- /dev/null +++ b/packages/cw4/src/hook.rs @@ -0,0 +1,63 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{to_binary, Binary, CosmosMsg, HumanAddr, StdResult, WasmMsg}; + +/// MemberChangedHookMsg should be de/serialized under `MemberChangedHook()` variant in a HandleMsg +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub struct MemberChangedHookMsg { + pub diffs: Vec, +} + +/// MemberDiff shows the old and new states for a given cw4 member +/// They cannot both be None. +/// old = None, new = Some -> Insert +/// old = Some, new = Some -> Update +/// old = Some, new = None -> Delete +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct MemberDiff { + pub addr: HumanAddr, + pub old_weight: Option, + pub new_weight: Option, +} + +impl MemberDiff { + pub fn new>( + addr: T, + old_weight: Option, + new_weight: Option, + ) -> Self { + MemberDiff { + addr: addr.into(), + old_weight, + new_weight, + } + } +} + +impl MemberChangedHookMsg { + /// serializes the message + pub fn into_binary(self) -> StdResult { + let msg = MemberChangedHandleMsg::MemberChangedHook(self); + to_binary(&msg) + } + + /// creates a cosmos_msg sending this struct to the named contract + pub fn into_cosmos_msg(self, contract_addr: HumanAddr) -> StdResult { + let msg = self.into_binary()?; + let execute = WasmMsg::Execute { + contract_addr, + msg, + send: vec![], + }; + Ok(execute.into()) + } +} + +// This is just a helper to properly serialize the above message +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +enum MemberChangedHandleMsg { + MemberChangedHook(MemberChangedHookMsg), +} diff --git a/packages/cw4/src/lib.rs b/packages/cw4/src/lib.rs index 1b4cc2c3c..43fc2940a 100644 --- a/packages/cw4/src/lib.rs +++ b/packages/cw4/src/lib.rs @@ -1,11 +1,13 @@ mod helpers; +mod hook; mod msg; mod query; pub use crate::helpers::{Cw4CanonicalContract, Cw4Contract}; +pub use crate::hook::{MemberChangedHookMsg, MemberDiff}; pub use crate::msg::{Cw4HandleMsg, Cw4InitMsg, Member}; pub use crate::query::{ - member_key, AdminResponse, Cw4QueryMsg, MemberListResponse, MemberResponse, + member_key, AdminResponse, Cw4QueryMsg, HooksResponse, MemberListResponse, MemberResponse, TotalWeightResponse, MEMBERS_KEY, TOTAL_KEY, }; diff --git a/packages/cw4/src/msg.rs b/packages/cw4/src/msg.rs index 8addc211a..5b86572de 100644 --- a/packages/cw4/src/msg.rs +++ b/packages/cw4/src/msg.rs @@ -32,4 +32,8 @@ pub enum Cw4HandleMsg { remove: Vec, add: Vec, }, + /// Add a new hook to be informed of all membership changes. Must be called by Admin + AddHook { addr: HumanAddr }, + /// Remove a hook. Must be called by Admin + RemoveHook { addr: HumanAddr }, } diff --git a/packages/cw4/src/query.rs b/packages/cw4/src/query.rs index 983f37ff6..077547f73 100644 --- a/packages/cw4/src/query.rs +++ b/packages/cw4/src/query.rs @@ -19,6 +19,8 @@ pub enum Cw4QueryMsg { }, /// Returns MemberResponse Member { addr: HumanAddr }, + /// Shows all registered hooks. Returns HooksResponse. + Hooks {}, } #[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] @@ -41,6 +43,11 @@ pub struct TotalWeightResponse { pub weight: u64, } +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct HooksResponse { + pub hooks: Vec, +} + /// TOTAL_KEY is meant for raw queries pub const TOTAL_KEY: &[u8] = b"total"; pub const MEMBERS_KEY: &[u8] = b"members";