CW4 (Member Group) 合约实现指南 —— MSG Chain
版本: v1.0
目标链: msg-chain-1 (bech32 前缀:msg)
主网状态: No-Go
标准: CosmWasm CW4 Group / Stake
适用场景: 多签 (cw3)、DAO 治理、子密钥权限管理
目录
1. 概述
1.1 什么是 CW4
CW4 是 CosmWasm 生态中定义组成员资格与权重的标准接口。它管理一个成员集合,每个成员拥有一个 weight(权重/投票权),供上层合约(如 CW3 多签、DAO 框架)查询和依赖。
核心概念:
| 概念 | 说明 |
|---|---|
Member |
地址 + 权重,构成组成员 |
total_weight |
所有成员权重之和 |
admin |
可修改成员列表的管理者 |
hook |
成员变更时的回调通知 |
1.2 两种变体
| 变体 | 成员管理方式 | 权重来源 | 适用场景 |
|---|---|---|---|
cw4_group |
静态——Admin 手动添加/移除 | 固定权重 | cw3-fixed-multisig、固定理事会 |
cw4_stake |
动态——任何人质押即可加入 | 质押数量 | 质押加权投票、DAO |
1.3 与上层合约的关系
cw4_group / cw4_stake
|
v
cw3_multisig (查询成员权重)
cw1_whitelist (查询子密钥权限)
DAO contracts (查询治理投票权)
|
v
MSG Chain 治理 (提案执行)
1.4 MSG Chain 集成要点
- 链 ID:
msg-chain-1 - 地址前缀:
msg - 原生代币:
uvoice(或umsg) - Gas 代币:
uvoice - 推荐合约版本:
cw-plus v1.1+ - Instantiate 权限:
Everybody(公开部署)或Admin(限白名单)
1.5 CW4 接口规范
CW4 定义了两个核心 trait:
// cosmwasm-std 中的分组 trait
pub trait Cw4Group {
fn member(&self, deps: Deps, addr: &Addr) -> Result<Option<Member>, ContractError>;
fn total_weight(&self, deps: Deps) -> Result<Uint128, ContractError>;
fn admin(&self, deps: Deps) -> Result<Option<Addr>, ContractError>;
fn add_hook(&mut self, deps: DepsMut, hook: Addr) -> Result<(), ContractError>;
fn remove_hook(&mut self, deps: DepsMut, hook: Addr) -> Result<(), ContractError>;
}
1.6 使用 CW4 的典型流程
┌──────────────────────┐
│ Deploy cw4_group │
│ or cw4_stake │
└──────────┬───────────┘
│
v
┌──────────────────────┐
│ Instantiate: │
│ - admin │
│ - members (group) │
│ - denom (stake) │
└──────────┬───────────┘
│
┌────────────┴────────────┐
v v
┌──────────────────┐ ┌──────────────────┐
│ Deploy cw3_flex │ │ Deploy DAO Core │
│ group_addr=... │ │ group_addr=... │
└────────┬─────────┘ └────────┬─────────┘
v v
┌──────────────────┐ ┌──────────────────┐
│ 创建提案 │ │ DAO 投票治理 │
│ cw4 查询权重 │ │ cw4 查询权重 │
│ 签名/投票 │ │ 执行提案 │
└──────────────────┘ └──────────────────┘
1.7 安全性考虑
- Admin 权限极大,应当使用多签地址作为 admin
- Hooks 注册方必须是可信合约(恶意 hook 可 DoS)
cw4_stake中的min_bond防止粉尘攻击- 权重溢出检查(Uint128 足够大,但加法仍需 checked_add)
2. cw4_group 静态组实现
2.1 状态结构
// state.rs
use cosmwasm_schema::cw_serde;
use cosmwasm_std::Addr;
use cw_storage_plus::{Item, Map};
use cw4::Member;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// cw4_group 合约的链上状态
#[cw_serde]
pub struct Cw4Group {
/// 管理员地址(None 表示不可变组)
pub admin: Option<Addr>,
/// 所有成员的权重总和
pub total_weight: Uint128,
}
/// 存储结构体
pub const CONFIG: Item<Cw4Group> = Item::new("config");
/// 成员映射: address -> Member{addr, weight}
pub const MEMBERS: Map<&Addr, Member> = Map::new("members");
/// Hook 注册表: hook_addr -> Empty
pub const HOOKS: Map<&Addr, Empty> = Map::new("hooks");
2.2 完整合约入口
// contract.rs
use cosmwasm_std::{
entry_point, to_json_binary, Binary, Deps, DepsMut, Empty, Env,
MessageInfo, Response, StdResult, Addr, Uint128, SubMsg, WasmMsg,
};
use cw4::{
Cw4ExecuteMsg, Cw4QueryMsg, Member, MemberResponse,
MembersResponse, TotalWeightResponse, AdminResponse,
HooksResponse, MemberChangedHookMsg,
};
use cw_controllers::Admin;
use crate::state::{CONFIG, MEMBERS, HOOKS, Cw4Group};
// ------------------------------------------------
// Instantiate
// ------------------------------------------------
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
let api = deps.api;
// 校验 admin 地址(如果提供)
let admin = msg
.admin
.map(|a| api.addr_validate(&a))
.transpose()?;
// 校验并去重成员列表
let members = validate_members(deps.api, &msg.members)?;
let total_weight = members
.iter()
.fold(Uint128::zero(), |acc, m| acc + m.weight);
// 保存配置
let config = Cw4Group {
admin: admin.clone(),
total_weight,
};
CONFIG.save(deps.storage, &config)?;
// 保存所有成员
for member in &members {
MEMBERS.save(deps.storage, &member.addr, member)?;
}
let mut res = Response::new()
.add_attribute("action", "instantiate")
.add_attribute("total_weight", total_weight.to_string());
if let Some(admin) = admin {
res = res.add_attribute("admin", admin);
}
Ok(res)
}
/// Instantiate 消息
#[cw_serde]
pub struct InstantiateMsg {
/// 管理员(None = 不可变)
pub admin: Option<String>,
/// 初始成员列表
pub members: Vec<Member>,
/// Hook 列表(可选)
pub hooks: Option<Vec<String>>,
}
/// 校验并返回去重后的成员列表
fn validate_members(api: &dyn Api, members: &[Member]) -> Result<Vec<Member>, ContractError> {
let mut seen = HashSet::new();
let mut validated = Vec::with_capacity(members.len());
for m in members {
let addr = api.addr_validate(&m.addr)?;
if !seen.insert(addr.clone()) {
return Err(ContractError::DuplicateMember { addr: addr.into() });
}
if m.weight.is_zero() {
return Err(ContractError::ZeroWeight { addr: addr.into() });
}
validated.push(Member {
addr: addr.into(),
weight: m.weight,
});
}
Ok(validated)
}
2.3 执行消息 (Execute)
// execute.rs
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::UpdateMembers { add, remove } => {
execute_update_members(deps, env, info, add, remove)
}
ExecuteMsg::UpdateAdmin { admin } => {
execute_update_admin(deps, env, info, admin)
}
ExecuteMsg::AddHook { addr } => {
execute_add_hook(deps, env, info, addr)
}
ExecuteMsg::RemoveHook { addr } => {
execute_remove_hook(deps, env, info, addr)
}
}
}
#[cw_serde]
pub enum ExecuteMsg {
/// 批量添加/移除成员(仅 admin)
UpdateMembers {
add: Vec<Member>,
remove: Vec<String>,
},
/// 更新 admin
UpdateAdmin {
admin: Option<String>,
},
/// 注册 hook(成员变更时回调)
AddHook {
addr: String,
},
/// 移除 hook
RemoveHook {
addr: String,
},
}
2.4 UpdateMembers 实现
// execute/update_members.rs
/// 更新组成员(添加 + 移除)
pub fn execute_update_members(
deps: DepsMut,
_env: Env,
info: MessageInfo,
add: Vec<Member>,
remove: Vec<String>,
) -> Result<Response, ContractError> {
// 仅 admin 可操作
let cfg = CONFIG.load(deps.storage)?;
let admin = cfg.admin.ok_or(ContractError::ImmutableGroup)?;
if info.sender != admin {
return Err(ContractError::Unauthorized {});
}
let api = deps.api;
let mut total_weight = cfg.total_weight;
// 1. 处理移除
let mut removed_hooks: Vec<MemberChangedHookMsg> = Vec::new();
for addr_str in &remove {
let addr = api.addr_validate(addr_str)?;
let member = MEMBERS.may_load(deps.storage, &addr)?;
if let Some(m) = member {
// 从映射中删除
MEMBERS.remove(deps.storage, &addr);
total_weight = total_weight.checked_sub(m.weight)?;
removed_hooks.push(m);
}
}
// 2. 处理添加
let mut added_hooks: Vec<MemberChangedHookMsg> = Vec::new();
for m in &add {
let addr = api.addr_validate(&m.addr)?;
if m.weight.is_zero() {
return Err(ContractError::ZeroWeight { addr: addr.into() });
}
// 如果已存在,先减去旧权重
if let Some(old) = MEMBERS.may_load(deps.storage, &addr)? {
total_weight = total_weight.checked_sub(old.weight)?;
MEMBERS.remove(deps.storage, &addr);
}
MEMBERS.save(deps.storage, &addr, m)?;
total_weight = total_weight.checked_add(m.weight)?;
added_hooks.push(Member {
addr: addr.into(),
weight: m.weight,
});
}
// 3. 更新 total_weight
CONFIG.save(
deps.storage,
&Cw4Group {
admin: cfg.admin,
total_weight,
},
)?;
// 4. 通知所有 hook
let hooks: Vec<Addr> = HOOKS
.keys(deps.storage, None, None, Order::Ascending)
.collect::<StdResult<Vec<_>>>()?;
let messages: Vec<SubMsg> = hooks
.into_iter()
.flat_map(|hook_addr| {
let mut msgs = Vec::new();
for m in &removed_hooks {
msgs.push(SubMsg::new(WasmMsg::Execute {
contract_addr: hook_addr.to_string(),
msg: to_json_binary(&MemberChangedHookMsg {
kind: "remove".to_string(),
member: m.clone(),
})?,
funds: vec![],
}));
}
for m in &added_hooks {
msgs.push(SubMsg::new(WasmMsg::Execute {
contract_addr: hook_addr.to_string(),
msg: to_json_binary(&MemberChangedHookMsg {
kind: "add".to_string(),
member: m.clone(),
})?,
funds: vec![],
}));
}
Ok::<_, ContractError>(msgs)
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
Ok(Response::new()
.add_attribute("action", "update_members")
.add_attribute("added", add.len().to_string())
.add_attribute("removed", remove.len().to_string())
.add_attribute("total_weight", total_weight.to_string())
.add_submessages(messages))
}
2.5 UpdateAdmin 实现
// execute/update_admin.rs
/// 更新管理员地址
pub fn execute_update_admin(
deps: DepsMut,
_env: Env,
info: MessageInfo,
admin: Option<String>,
) -> Result<Response, ContractError> {
let cfg = CONFIG.load(deps.storage)?;
let current_admin = cfg.admin.ok_or(ContractError::ImmutableGroup)?;
if info.sender != current_admin {
return Err(ContractError::Unauthorized {});
}
// 校验新 admin 地址
let new_admin = admin
.map(|a| deps.api.addr_validate(&a))
.transpose()?;
CONFIG.save(
deps.storage,
&Cw4Group {
admin: new_admin.clone(),
total_weight: cfg.total_weight,
},
)?;
let mut res = Response::new()
.add_attribute("action", "update_admin");
if let Some(ref a) = new_admin {
res = res.add_attribute("admin", a);
} else {
res = res.add_attribute("admin", "none");
}
Ok(res)
}
2.6 Hook 管理实现
// execute/hooks.rs
/// 注册 Hook——成员变更时通知
pub fn execute_add_hook(
deps: DepsMut,
_env: Env,
info: MessageInfo,
hook_addr: String,
) -> Result<Response, ContractError> {
let cfg = CONFIG.load(deps.storage)?;
let admin = cfg.admin.ok_or(ContractError::ImmutableGroup)?;
if info.sender != admin {
return Err(ContractError::Unauthorized {});
}
let addr = deps.api.addr_validate(&hook_addr)?;
HOOKS.save(deps.storage, &addr, &Empty {})?;
Ok(Response::new()
.add_attribute("action", "add_hook")
.add_attribute("hook", addr))
}
/// 移除 Hook
pub fn execute_remove_hook(
deps: DepsMut,
_env: Env,
info: MessageInfo,
hook_addr: String,
) -> Result<Response, ContractError> {
let cfg = CONFIG.load(deps.storage)?;
let admin = cfg.admin.ok_or(ContractError::ImmutableGroup)?;
if info.sender != admin {
return Err(ContractError::Unauthorized {});
}
let addr = deps.api.addr_validate(&hook_addr)?;
HOOKS.remove(deps.storage, &addr);
Ok(Response::new()
.add_attribute("action", "remove_hook")
.add_attribute("hook", addr))
}
2.7 查询实现
// query.rs
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, _env: Env, msg: Cw4QueryMsg) -> StdResult<Binary> {
match msg {
Cw4QueryMsg::Admin {} => to_json_binary(&query_admin(deps)?),
Cw4QueryMsg::TotalWeight {} => to_json_binary(&query_total_weight(deps)?),
Cw4QueryMsg::Member { addr, at_height } => {
to_json_binary(&query_member(deps, addr, at_height)?)
}
Cw4QueryMsg::ListMembers { start_after, limit } => {
to_json_binary(&query_list_members(deps, start_after, limit)?)
}
Cw4QueryMsg::Hooks {} => to_json_binary(&query_hooks(deps)?),
}
}
pub fn query_admin(deps: Deps) -> StdResult<AdminResponse> {
let cfg = CONFIG.load(deps.storage)?;
Ok(AdminResponse {
admin: cfg.admin.map(|a| a.to_string()),
})
}
pub fn query_total_weight(deps: Deps) -> StdResult<TotalWeightResponse> {
let cfg = CONFIG.load(deps.storage)?;
Ok(TotalWeightResponse {
weight: cfg.total_weight,
})
}
pub fn query_member(
deps: Deps,
addr: String,
at_height: Option<u64>,
) -> StdResult<MemberResponse> {
let addr = deps.api.addr_validate(&addr)?;
// at_height 扩展:可通过历史快照查询(cw4 暂未实现,预留)
let member = MEMBERS.may_load(deps.storage, &addr)?;
// 如果指定了高度,校验该高度是否超过当前块高度
if let Some(height) = at_height {
let block = deps.block_info;
if height > block.height {
return Err(StdError::generic_err(format!(
"at_height {} exceeds current block height {}",
height, block.height
)));
}
// 实际生产环境需要历史状态存储,这里仅做占位
}
Ok(MemberResponse { member })
}
pub fn query_list_members(
deps: Deps,
start_after: Option<String>,
limit: Option<u32>,
) -> StdResult<MembersResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
let start = start_after
.as_ref()
.map(|s| deps.api.addr_validate(s))
.transpose()?;
let members: StdResult<Vec<_>> = MEMBERS
.range(deps.storage, start.as_ref(), None, Order::Ascending)
.take(limit)
.map(|item| {
let (_, member) = item?;
Ok(Member {
addr: member.addr,
weight: member.weight,
})
})
.collect();
Ok(MembersResponse { members: members? })
}
pub fn query_hooks(deps: Deps) -> StdResult<HooksResponse> {
let hooks: Vec<String> = HOOKS
.keys(deps.storage, None, None, Order::Ascending)
.map(|a| a.map(|addr| addr.to_string()))
.collect::<StdResult<_>>()?;
Ok(HooksResponse { hooks })
}
2.8 错误类型定义
// error.rs
use cosmwasm_std::StdError;
use cw_utils::PaymentError;
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum ContractError {
#[error("{0}")]
Std(#[from] StdError),
#[error("{0}")]
Payment(#[from] PaymentError),
#[error("Unauthorized")]
Unauthorized {},
#[error("Group is immutable (no admin)")]
ImmutableGroup {},
#[error("Duplicate member: {addr}")]
DuplicateMember { addr: String },
#[error("Member {addr} has zero weight")]
ZeroWeight { addr: String },
#[error("Hook already registered: {addr}")]
HookAlreadyRegistered { addr: String },
#[error("Hook not found: {addr}")]
HookNotFound { addr: String },
#[error("Overflow: {msg}")]
Overflow { msg: String },
}
impl From<ContractError> for StdError {
fn from(e: ContractError) -> Self {
StdError::generic_err(e.to_string())
}
}
2.9 cw4_group 单元测试
// tests/integration_test.rs
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::{
mock_dependencies, mock_env, mock_info,
};
use cosmwasm_std::{from_json, Addr, Uint128};
fn default_members() -> Vec<Member> {
vec![
Member {
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into(),
weight: Uint128::from(10u128),
},
Member {
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2nc07r".into(),
weight: Uint128::from(20u128),
},
Member {
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhyv3mp".into(),
weight: Uint128::from(30u128),
},
]
}
fn setup_test() -> (DepsMut, Env, MessageInfo) {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
let res = instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap();
assert_eq!(res.attributes[0].value, "instantiate");
(deps.as_mut(), env, info)
}
#[test]
fn test_instantiate_works() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("creator", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
let res = instantiate(deps.as_mut(), env, info, msg).unwrap();
assert_eq!(res.attributes.len(), 3);
assert_eq!(res.attributes[0].value, "instantiate");
// 验证 total_weight
let tw = query_total_weight(deps.as_ref()).unwrap();
assert_eq!(tw.weight, Uint128::from(60u128));
// 验证成员数
let list = query_list_members(deps.as_ref(), None, None).unwrap();
assert_eq!(list.members.len(), 3);
}
#[test]
fn test_instantiate_duplicate_fails() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("creator", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: vec![
Member {
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into(),
weight: Uint128::from(10u128),
},
Member {
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into(),
weight: Uint128::from(10u128),
},
],
hooks: None,
};
let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err();
assert!(matches!(err, ContractError::DuplicateMember { .. }));
}
#[test]
fn test_instantiate_zero_weight_fails() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("creator", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: vec![Member {
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into(),
weight: Uint128::zero(),
}],
hooks: None,
};
let err = instantiate(deps.as_mut(), env, info, msg).unwrap_err();
assert!(matches!(err, ContractError::ZeroWeight { .. }));
}
#[test]
fn test_update_members_add() {
let mut deps = mock_dependencies();
// instantiate
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: vec![Member {
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into(),
weight: Uint128::from(10u128),
}],
hooks: None,
};
instantiate(deps.as_mut(), env, info, msg).unwrap();
// add
let env = mock_env();
let info = mock_info("admin", &[]);
let add = vec![Member {
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2nc07r".into(),
weight: Uint128::from(20u128),
}];
let msg = ExecuteMsg::UpdateMembers {
add,
remove: vec![],
};
execute(deps.as_mut(), env, info, msg).unwrap();
let list = query_list_members(deps.as_ref(), None, None).unwrap();
assert_eq!(list.members.len(), 2);
let tw = query_total_weight(deps.as_ref()).unwrap();
assert_eq!(tw.weight, Uint128::from(30u128));
}
#[test]
fn test_update_members_remove() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap();
// remove
let msg = ExecuteMsg::UpdateMembers {
add: vec![],
remove: vec!["msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into()],
};
execute(deps.as_mut(), env, info, msg).unwrap();
let list = query_list_members(deps.as_ref(), None, None).unwrap();
assert_eq!(list.members.len(), 2);
assert_eq!(
list.members[0].addr,
"msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2nc07r"
);
let tw = query_total_weight(deps.as_ref()).unwrap();
assert_eq!(tw.weight, Uint128::from(50u128));
}
#[test]
fn test_unauthorized_fails() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap();
// 非 admin 更新成员
let info = mock_info("hacker", &[]);
let msg = ExecuteMsg::UpdateMembers {
add: vec![],
remove: vec![],
};
let err = execute(deps.as_mut(), env.clone(), info, msg).unwrap_err();
assert_eq!(err, ContractError::Unauthorized {});
}
#[test]
fn test_immutable_group() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("creator", &[]);
let msg = InstantiateMsg {
admin: None, // immutable
members: default_members(),
hooks: None,
};
instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap();
let admin_resp = query_admin(deps.as_ref()).unwrap();
assert_eq!(admin_resp.admin, None);
// 尝试更新成员(应该失败)
let info = mock_info("anyone", &[]);
let msg = ExecuteMsg::UpdateMembers {
add: vec![],
remove: vec!["msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into()],
};
let err = execute(deps.as_mut(), env, info, msg).unwrap_err();
assert_eq!(err, ContractError::ImmutableGroup {});
}
#[test]
fn test_hooks() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
instantiate(deps.as_mut(), env, info, msg).unwrap();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = ExecuteMsg::AddHook {
addr: "hook_contract".into(),
};
execute(deps.as_mut(), env, info, msg).unwrap();
let hooks = query_hooks(deps.as_ref()).unwrap();
assert_eq!(hooks.hooks, vec!["hook_contract"]);
}
#[test]
fn test_query_member() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
instantiate(deps.as_mut(), env, info, msg).unwrap();
let resp = query_member(
deps.as_ref(),
"msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into(),
None,
)
.unwrap();
assert!(resp.member.is_some());
assert_eq!(
resp.member.unwrap().weight,
Uint128::from(10u128)
);
// 不存在的成员
let resp = query_member(
deps.as_ref(),
"msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqx0v0v0v".into(),
None,
)
.unwrap();
assert!(resp.member.is_none());
}
#[test]
fn test_history_not_supported() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
instantiate(deps.as_mut(), env, info, msg).unwrap();
let err = query_member(
deps.as_ref(),
"msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn".into(),
Some(99999999), // far future
)
.unwrap_err();
assert!(err.to_string().contains("exceeds current block height"));
}
#[test]
fn test_update_admin() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap();
// 更新 admin
let msg = ExecuteMsg::UpdateAdmin {
admin: Some("new_admin".into()),
};
execute(deps.as_mut(), env.clone(), info, msg).unwrap();
let admin_resp = query_admin(deps.as_ref()).unwrap();
assert_eq!(admin_resp.admin, Some("new_admin".into()));
// 旧 admin 不能再操作
let info = mock_info("admin", &[]);
let msg = ExecuteMsg::UpdateMembers {
add: vec![],
remove: vec![],
};
let err = execute(deps.as_mut(), env.clone(), info, msg).unwrap_err();
assert_eq!(err, ContractError::Unauthorized {});
}
#[test]
fn test_remove_admin_set_immutable() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
members: default_members(),
hooks: None,
};
instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap();
// 移除 admin(设为 None、不可变)
let msg = ExecuteMsg::UpdateAdmin { admin: None };
execute(deps.as_mut(), env.clone(), info, msg).unwrap();
let admin_resp = query_admin(deps.as_ref()).unwrap();
assert_eq!(admin_resp.admin, None);
// 任何人不可再修改
let info = mock_info("anyone", &[]);
let msg = ExecuteMsg::UpdateMembers {
add: vec![],
remove: vec![],
};
let err = execute(deps.as_mut(), env, info, msg).unwrap_err();
assert_eq!(err, ContractError::ImmutableGroup {});
}
}
2.10 Lib 入口
// lib.rs
pub mod contract;
pub mod state;
pub mod error;
pub mod msg;
pub mod execute;
pub mod query;
pub use crate::error::ContractError;
pub use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
pub use crate::contract::{instantiate, execute, query};
3. cw4_stake 动态组实现
3.1 设计思路
cw4_stake 是 CW4 的动态变体,成员资格与权重完全由质押金额决定。
核心逻辑:
- 用户通过
Bond{}消息发送代币到合约 - 合约记录用户质押量,权重 = 质押量
- 用户通过
Unbond{}消息解锁代币 - 解锁有锁定期(如 7 天),到期后可提取
- 如果解锁后余额 <
min_bond,则从成员列表中移除
3.2 状态结构
// cw4_stake/state.rs
use cosmwasm_schema::cw_serde;
use cosmwasm_std::{Addr, Uint128, Empty};
use cw_storage_plus::{Item, Map, SnapshotMap, Strategy};
use cw_controllers::Admin;
use cw4::Member;
/// cw4_stake 合约状态
#[cw_serde]
pub struct Config {
/// 质押接受的原生代币 denom(如 "uvoice")
pub denom: String,
/// 最小质押量——低于此值不是成员
pub min_bond: Uint128,
/// 解锁期(秒)
pub unbonding_period: Duration,
}
/// 单个用户的质押信息
#[cw_serde]
pub struct StakeInfo {
/// 当前质押量
pub stake: Uint128,
/// 正在解锁中的总量
pub unbonding: Uint128,
/// 是否被惩罚(slashed)
pub slashed: bool,
}
/// 解锁记录
#[cw_serde]
pub struct UnbondingInfo {
/// 解锁数量
pub amount: Uint128,
/// 解锁开始时间
pub start: Timestamp,
/// 可提取时间(start + unbonding_period)
pub release_at: Timestamp,
}
pub const CONFIG: Item<Config> = Item::new("config");
pub const TOTAL_BONDED: Item<Uint128> = Item::new("total_bonded");
pub const TOTAL_WEIGHT: Item<Uint128> = Item::new("total_weight");
pub const STAKE: Map<&Addr, StakeInfo> = Map::new("stake");
pub const UNBONDING_QUEUE: Map<(&Addr, u64), UnbondingInfo> = Map::new("unbonding");
/// members 保留权重快照(优化 CW3 查询)
pub const MEMBERS: Map<&Addr, Member> = Map::new("members");
pub const HOOKS: Map<&Addr, Empty> = Map::new("hooks");
/// 解锁记录计数器(用于 key 唯一性)
pub const UNBONDING_COUNTER: Item<u64> = Item::new("unbonding_counter");
3.3 合约入口与消息定义
// cw4_stake/msg.rs
#[cw_serde]
pub enum ExecuteMsg {
/// 质押代币(发送的 funds 会被自动质押)
Bond {},
/// 发起解锁
Unbond {
amount: Uint128,
},
/// 提取已到期的解锁代币
Claim {},
/// 更新配置(仅 admin)
UpdateConfig {
min_bond: Option<Uint128>,
unbonding_period: Option<Duration>,
},
/// 更新 admin
UpdateAdmin {
admin: Option<String>,
},
/// 注册 hook
AddHook {
addr: String,
},
/// 移除 hook
RemoveHook {
addr: String,
},
}
#[cw_serde]
pub enum QueryMsg {
/// 配置信息
Config {},
/// 总质押量
TotalBonded {},
/// 用户质押详情
StakeInfo {
addr: String,
},
/// 解锁记录列表
UnbondingHistory {
addr: String,
},
// --- CW4 标准查询 ---
Admin {},
TotalWeight {},
Member {
addr: String,
at_height: Option<u64>,
},
ListMembers {
start_after: Option<String>,
limit: Option<u32>,
},
Hooks {},
}
3.4 Instantiate
// cw4_stake/contract.rs
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
let api = deps.api;
// 校验配置
if msg.min_bond.is_zero() {
return Err(ContractError::InvalidZeroAmount {});
}
let admin = msg
.admin
.map(|a| api.addr_validate(&a))
.transpose()?;
let config = Config {
denom: msg.denom.clone(),
min_bond: msg.min_bond,
unbonding_period: msg.unbonding_period,
};
CONFIG.save(deps.storage, &config)?;
TOTAL_BONDED.save(deps.storage, &Uint128::zero())?;
TOTAL_WEIGHT.save(deps.storage, &Uint128::zero())?;
UNBONDING_COUNTER.save(deps.storage, &0u64)?;
// 保存 admin (使用 cw_controllers::Admin)
let cw_admin = Admin::new("admin");
if let Some(a) = admin {
cw_admin.set(deps, Some(a))?;
}
Ok(Response::new()
.add_attribute("action", "instantiate")
.add_attribute("denom", &msg.denom)
.add_attribute("min_bond", msg.min_bond.to_string()))
}
#[cw_serde]
pub struct InstantiateMsg {
pub admin: Option<String>,
pub denom: String,
pub min_bond: Uint128,
pub unbonding_period: Duration,
}
3.5 Execute 入口
// cw4_stake/execute.rs
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::Bond {} => execute_bond(deps, env, info),
ExecuteMsg::Unbond { amount } => execute_unbond(deps, env, info, amount),
ExecuteMsg::Claim {} => execute_claim(deps, env, info),
ExecuteMsg::UpdateConfig {
min_bond,
unbonding_period,
} => execute_update_config(deps, env, info, min_bond, unbonding_period),
ExecuteMsg::UpdateAdmin { admin } => execute_update_admin(deps, env, info, admin),
ExecuteMsg::AddHook { addr } => execute_add_hook(deps, env, info, addr),
ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, env, info, addr),
}
}
3.6 Bond 质押
// cw4_stake/bond.rs
/// 用户质押代币
pub fn execute_bond(
deps: DepsMut,
_env: Env,
info: MessageInfo,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
// 必须传入恰好一种原生代币
let payment = cw_utils::one_coin(&info)?;
if payment.denom != config.denom {
return Err(ContractError::WrongDenom {
expected: config.denom,
got: payment.denom,
});
}
let amount = payment.amount;
if amount.is_zero() {
return Err(ContractError::InvalidZeroAmount {});
}
let addr = info.sender;
// 更新用户质押信息
let mut stake = STAKE
.may_load(deps.storage, &addr)?
.unwrap_or(StakeInfo {
stake: Uint128::zero(),
unbonding: Uint128::zero(),
slashed: false,
});
stake.stake = stake.stake.checked_add(amount)?;
STAKE.save(deps.storage, &addr, &stake)?;
// 更新全局统计
let mut total_bonded = TOTAL_BONDED.load(deps.storage)?;
total_bonded = total_bonded.checked_add(amount)?;
TOTAL_BONDED.save(deps.storage, &total_bonded)?;
// 如果达到 min_bond,加入成员列表
let mut total_weight = TOTAL_WEIGHT.load(deps.storage)?;
let mut was_already_member = false;
let mut old_weight = Uint128::zero();
if let Some(member) = MEMBERS.may_load(deps.storage, &addr)? {
was_already_member = true;
old_weight = member.weight;
}
if stake.stake >= config.min_bond {
total_weight = total_weight
.checked_sub(old_weight)?
.checked_add(amount)?;
MEMBERS.save(
deps.storage,
&addr,
&Member {
addr: addr.to_string(),
weight: stake.stake,
},
)?;
}
TOTAL_WEIGHT.save(deps.storage, &total_weight)?;
let action = if was_already_member {
"bond_additional"
} else {
"bond_new"
};
// 通知 hooks
let hook_msg = MemberChangedHookMsg {
kind: if was_already_member {
"weight_updated".to_string()
} else {
"added".to_string()
},
member: Member {
addr: addr.to_string(),
weight: stake.stake,
},
};
let hook_msgs = notify_hooks(deps.as_ref(), hook_msg)?;
Ok(Response::new()
.add_attribute("action", action)
.add_attribute("sender", &addr)
.add_attribute("amount", amount.to_string())
.add_attribute("total_bonded", total_bonded.to_string())
.add_attribute("total_weight", total_weight.to_string())
.add_submessages(hook_msgs))
}
3.7 Unbond 解锁
// cw4_stake/unbond.rs
/// 用户发起解锁
pub fn execute_unbond(
deps: DepsMut,
env: Env,
info: MessageInfo,
amount: Uint128,
) -> Result<Response, ContractError> {
if amount.is_zero() {
return Err(ContractError::InvalidZeroAmount {});
}
let config = CONFIG.load(deps.storage)?;
let addr = info.sender;
let mut stake = STAKE
.may_load(deps.storage, &addr)?
.ok_or(ContractError::NoStake {})?;
if amount > stake.stake {
return Err(ContractError::InsufficientStake {
available: stake.stake,
requested: amount,
});
}
// 扣除质押
stake.stake = stake.stake.checked_sub(amount)?;
stake.unbonding = stake.unbonding.checked_add(amount)?;
STAKE.save(deps.storage, &addr, &stake)?;
// 创建解锁记录
let counter = UNBONDING_COUNTER.load(deps.storage)?;
let new_counter = counter.checked_add(1).ok_or(StdError::generic_err("counter overflow"))?;
UNBONDING_COUNTER.save(deps.storage, &new_counter)?;
let release_at = env.block.time.plus_seconds(config.unbonding_period.as_secs());
let unbonding = UnbondingInfo {
amount,
start: env.block.time,
release_at,
};
UNBONDING_QUEUE.save(deps.storage, (&addr, counter), &unbonding)?;
// 更新全局统计
let mut total_bonded = TOTAL_BONDED.load(deps.storage)?;
total_bonded = total_bonded.checked_sub(amount)?;
TOTAL_BONDED.save(deps.storage, &total_bonded)?;
// 更新成员状态
let mut total_weight = TOTAL_WEIGHT.load(deps.storage)?;
let new_stake = stake.stake;
// 如果原来有成员记录
if let Some(member) = MEMBERS.may_load(deps.storage, &addr)? {
total_weight = total_weight.checked_sub(member.weight)?;
if new_stake < config.min_bond {
// 低于 min_bond,移除成员
MEMBERS.remove(deps.storage, &addr);
} else {
// 仍在成员列表,更新权重
MEMBERS.save(
deps.storage,
&addr,
&Member {
addr: addr.to_string(),
weight: new_stake,
},
)?;
total_weight = total_weight.checked_add(new_stake)?;
}
}
TOTAL_WEIGHT.save(deps.storage, &total_weight)?;
// 通知 hooks
let hook_msg = MemberChangedHookMsg {
kind: "weight_updated".to_string(),
member: Member {
addr: addr.to_string(),
weight: new_stake,
},
};
let hook_msgs = notify_hooks(deps.as_ref(), hook_msg)?;
Ok(Response::new()
.add_attribute("action", "unbond")
.add_attribute("sender", &addr)
.add_attribute("amount", amount.to_string())
.add_attribute("release_at", release_at.to_string())
.add_attribute("total_bonded", total_bonded.to_string())
.add_submessages(hook_msgs))
}
3.8 Claim 提取
// cw4_stake/claim.rs
/// 提取已到期的解锁代币
pub fn execute_claim(
deps: DepsMut,
env: Env,
info: MessageInfo,
) -> Result<Response, ContractError> {
let addr = info.sender;
let config = CONFIG.load(deps.storage)?;
let mut stake = STAKE
.may_load(deps.storage, &addr)?
.ok_or(ContractError::NoStake {})?;
let now = env.block.time;
// 遍历所有解锁记录,找出已到期的
let mut total_claimable = Uint128::zero();
let mut to_remove = Vec::new();
// UNBONDING_QUEUE prefix iterator
let keys: Vec<(Addr, u64)> = UNBONDING_QUEUE
.prefix(&addr)
.keys(deps.storage, None, None, Order::Ascending)
.collect::<StdResult<_>>()?;
for counter in keys {
let unbonding = UNBONDING_QUEUE.load(deps.storage, (&addr, counter))?;
if now >= unbonding.release_at {
total_claimable = total_claimable.checked_add(unbonding.amount)?;
to_remove.push(counter);
}
}
if total_claimable.is_zero() {
return Err(ContractError::NoClaimableFunds {});
}
// 移除已处理的解锁记录
for counter in to_remove {
UNBONDING_QUEUE.remove(deps.storage, (&addr, counter));
}
// 更新用户 unbonding 余额
stake.unbonding = stake.unbonding.checked_sub(total_claimable)?;
STAKE.save(deps.storage, &addr, &stake)?;
// 转账
let bank_msg = BankMsg::Send {
to_address: addr.to_string(),
amount: coins(total_claimable.u128(), &config.denom),
};
Ok(Response::new()
.add_attribute("action", "claim")
.add_attribute("sender", &addr)
.add_attribute("amount", total_claimable.to_string())
.add_message(bank_msg))
}
3.9 UpdateConfig
// cw4_stake/update_config.rs
pub fn execute_update_config(
deps: DepsMut,
_env: Env,
info: MessageInfo,
min_bond: Option<Uint128>,
unbonding_period: Option<Duration>,
) -> Result<Response, ContractError> {
// 仅 admin
let admin = Admin::new("admin");
admin.assert_admin(deps.as_ref(), &info.sender)?;
let mut config = CONFIG.load(deps.storage)?;
if let Some(min_bond) = min_bond {
if min_bond.is_zero() {
return Err(ContractError::InvalidZeroAmount {});
}
config.min_bond = min_bond;
}
if let Some(period) = unbonding_period {
config.unbonding_period = period;
}
CONFIG.save(deps.storage, &config)?;
Ok(Response::new()
.add_attribute("action", "update_config")
.add_attribute("min_bond", config.min_bond.to_string()))
}
3.10 Hook 通知辅助函数
// cw4_stake/hooks.rs
/// 向所有注册的 hook 发送通知
pub fn notify_hooks(
deps: Deps,
msg: MemberChangedHookMsg,
) -> Result<Vec<SubMsg>, ContractError> {
let hooks: Vec<Addr> = HOOKS
.keys(deps.storage, None, None, Order::Ascending)
.collect::<StdResult<_>>()?;
if hooks.is_empty() {
return Ok(vec![]);
}
let execute_msg = WasmMsg::Execute {
contract_addr: String::new(), // placeholder
msg: to_json_binary(&msg)?,
funds: vec![],
};
let sub_msgs: Vec<SubMsg> = hooks
.into_iter()
.map(|addr| {
let mut msg = execute_msg.clone();
msg.contract_addr = addr.to_string();
SubMsg::new(msg)
})
.collect();
Ok(sub_msgs)
}
3.11 cw4_stake 查询
// cw4_stake/query.rs
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::Config {} => to_json_binary(&query_config(deps)?),
QueryMsg::TotalBonded {} => to_json_binary(&query_total_bonded(deps)?),
QueryMsg::StakeInfo { addr } => to_json_binary(&query_stake_info(deps, addr)?),
QueryMsg::UnbondingHistory { addr } => {
to_json_binary(&query_unbonding_history(deps, addr)?)
}
QueryMsg::Admin {} => to_json_binary(&query_admin(deps)?),
QueryMsg::TotalWeight {} => to_json_binary(&query_total_weight(deps)?),
QueryMsg::Member { addr, at_height } => {
to_json_binary(&query_member(deps, addr, at_height)?)
}
QueryMsg::ListMembers { start_after, limit } => {
to_json_binary(&query_list_members(deps, start_after, limit)?)
}
QueryMsg::Hooks {} => to_json_binary(&query_hooks(deps)?),
}
}
pub fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
let config = CONFIG.load(deps.storage)?;
Ok(ConfigResponse {
denom: config.denom,
min_bond: config.min_bond,
unbonding_period: config.unbonding_period,
})
}
pub fn query_total_bonded(deps: Deps) -> StdResult<TotalBondedResponse> {
let total = TOTAL_BONDED.load(deps.storage)?;
Ok(TotalBondedResponse { total })
}
pub fn query_stake_info(deps: Deps, addr: String) -> StdResult<StakeInfoResponse> {
let addr = deps.api.addr_validate(&addr)?;
let stake = STAKE.may_load(deps.storage, &addr)?;
Ok(StakeInfoResponse {
stake: stake.as_ref().map(|s| s.stake).unwrap_or_default(),
unbonding: stake.as_ref().map(|s| s.unbonding).unwrap_or_default(),
})
}
pub fn query_unbonding_history(
deps: Deps,
addr: String,
) -> StdResult<UnbondingHistoryResponse> {
let addr = deps.api.addr_validate(&addr)?;
let records: StdResult<Vec<_>> = UNBONDING_QUEUE
.prefix(&addr)
.range(deps.storage, None, None, Order::Ascending)
.map(|r| {
let (counter, info) = r?;
Ok(UnbondingRecord {
id: counter,
amount: info.amount,
start: info.start,
release_at: info.release_at,
})
})
.collect();
Ok(UnbondingHistoryResponse {
records: records?,
})
}
/// CW4 标准查询——复用 cw4_group 的逻辑
pub fn query_admin(deps: Deps) -> StdResult<AdminResponse> {
let admin = Admin::new("admin");
let a = admin.get(deps)?;
Ok(AdminResponse {
admin: a.map(|a| a.to_string()),
})
}
pub fn query_total_weight(deps: Deps) -> StdResult<TotalWeightResponse> {
let weight = TOTAL_WEIGHT.load(deps.storage)?;
Ok(TotalWeightResponse { weight })
}
pub fn query_member(
deps: Deps,
addr: String,
_at_height: Option<u64>,
) -> StdResult<MemberResponse> {
let addr = deps.api.addr_validate(&addr)?;
let member = MEMBERS.may_load(deps.storage, &addr)?;
Ok(MemberResponse { member })
}
pub fn query_list_members(
deps: Deps,
start_after: Option<String>,
limit: Option<u32>,
) -> StdResult<MembersResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
let start = start_after
.as_ref()
.map(|s| deps.api.addr_validate(s))
.transpose()?;
let members: StdResult<Vec<Member>> = MEMBERS
.range(deps.storage, start.as_ref(), None, Order::Ascending)
.take(limit)
.map(|r| {
let (_, m) = r?;
Ok(m)
})
.collect();
Ok(MembersResponse {
members: members?,
})
}
pub fn query_hooks(deps: Deps) -> StdResult<HooksResponse> {
let hooks: StdResult<Vec<String>> = HOOKS
.keys(deps.storage, None, None, Order::Ascending)
.map(|a| a.map(|addr| addr.to_string()))
.collect();
Ok(HooksResponse { hooks: hooks? })
}
// --- Response types ---
#[cw_serde]
pub struct ConfigResponse {
pub denom: String,
pub min_bond: Uint128,
pub unbonding_period: Duration,
}
#[cw_serde]
pub struct TotalBondedResponse {
pub total: Uint128,
}
#[cw_serde]
pub struct StakeInfoResponse {
pub stake: Uint128,
pub unbonding: Uint128,
}
#[cw_serde]
pub struct UnbondingRecord {
pub id: u64,
pub amount: Uint128,
pub start: Timestamp,
pub release_at: Timestamp,
}
#[cw_serde]
pub struct UnbondingHistoryResponse {
pub records: Vec<UnbondingRecord>,
}
3.12 cw4_stake 单元测试
// cw4_stake/tests/stake_test.rs
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::{
mock_dependencies, mock_dependencies_with_balance, mock_env, mock_info,
};
use cosmwasm_std::{coins, from_json, Addr, BlockInfo, Timestamp, Uint128};
use cw_utils::Duration;
const DENOM: &str = "uvoice";
const MIN_BOND: Uint128 = Uint128::new(100);
const UNBONDING_SECONDS: u64 = 604800; // 7 days
fn setup_stake_test() -> (DepsMut, Env, MessageInfo) {
let mut deps = mock_dependencies_with_balance(&coins(0, DENOM));
let env = mock_env();
let info = mock_info("creator", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
denom: DENOM.to_string(),
min_bond: MIN_BOND,
unbonding_period: Duration::Time(UNBONDING_SECONDS),
};
let res = instantiate(deps.as_mut(), env.clone(), info.clone(), msg).unwrap();
assert_eq!(res.attributes[0].value, "instantiate");
(deps.as_mut(), env, info)
}
#[test]
fn test_instantiate_stake() {
let mut deps = mock_dependencies();
let env = mock_env();
let info = mock_info("creator", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
denom: DENOM.to_string(),
min_bond: MIN_BOND,
unbonding_period: Duration::Time(UNBONDING_SECONDS),
};
let res = instantiate(deps.as_mut(), env, info, msg).unwrap();
assert_eq!(res.attributes.len(), 3);
// 查询配置
let config = query_config(deps.as_ref()).unwrap();
assert_eq!(config.denom, DENOM);
assert_eq!(config.min_bond, MIN_BOND);
// 没有成员
let members = query_list_members(deps.as_ref(), None, None).unwrap();
assert!(members.members.is_empty());
let total = query_total_weight(deps.as_ref()).unwrap();
assert_eq!(total.weight, Uint128::zero());
}
#[test]
fn test_bond_becomes_member() {
let mut deps = mock_dependencies_with_balance(&coins(0, DENOM));
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
denom: DENOM.to_string(),
min_bond: Uint128::new(100),
unbonding_period: Duration::Time(604800),
};
instantiate(deps.as_mut(), env.clone(), info, msg).unwrap();
// 用户质押 500 uvoice
let user_info = mock_info("user1", &coins(500, DENOM));
let msg = ExecuteMsg::Bond {};
execute(deps.as_mut(), env.clone(), user_info, msg).unwrap();
// 验证成员
let member = query_member(
deps.as_ref(),
"user1".into(),
None,
)
.unwrap();
assert!(member.member.is_some());
assert_eq!(member.member.unwrap().weight, Uint128::new(500));
let total = query_total_weight(deps.as_ref()).unwrap();
assert_eq!(total.weight, Uint128::new(500));
let bonded = query_total_bonded(deps.as_ref()).unwrap();
assert_eq!(bonded.total, Uint128::new(500));
}
#[test]
fn test_bond_below_min_bond() {
let mut deps = mock_dependencies_with_balance(&coins(0, DENOM));
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
denom: DENOM.to_string(),
min_bond: Uint128::new(100),
unbonding_period: Duration::Time(604800),
};
instantiate(deps.as_mut(), env.clone(), info, msg).unwrap();
// 质押 50(低于 min_bond 100)
let user_info = mock_info("small_user", &coins(50, DENOM));
let msg = ExecuteMsg::Bond {};
execute(deps.as_mut(), env.clone(), user_info, msg).unwrap();
// 不应成为成员
let member = query_member(
deps.as_ref(),
"small_user".into(),
None,
)
.unwrap();
assert!(member.member.is_none());
// 但 stake 记录存在
let stake_info = query_stake_info(deps.as_ref(), "small_user".into()).unwrap();
assert_eq!(stake_info.stake, Uint128::new(50));
}
#[test]
fn test_unbond_reduces_weight() {
let mut deps = mock_dependencies_with_balance(&coins(0, DENOM));
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
denom: DENOM.to_string(),
min_bond: Uint128::new(100),
unbonding_period: Duration::Time(604800),
};
instantiate(deps.as_mut(), env.clone(), info, msg).unwrap();
// 质押 500
let user_info = mock_info("user1", &coins(500, DENOM));
let msg = ExecuteMsg::Bond {};
execute(deps.as_mut(), env.clone(), user_info.clone(), msg).unwrap();
// 解锁 300
let msg = ExecuteMsg::Unbond {
amount: Uint128::new(300),
};
execute(deps.as_mut(), env.clone(), user_info, msg).unwrap();
// 权重变为 200
let member = query_member(deps.as_ref(), "user1".into(), None).unwrap();
assert!(member.member.is_some());
assert_eq!(member.member.unwrap().weight, Uint128::new(200));
let total = query_total_weight(deps.as_ref()).unwrap();
assert_eq!(total.weight, Uint128::new(200));
}
#[test]
fn test_unbond_below_min_bond_removes_member() {
let mut deps = mock_dependencies_with_balance(&coins(0, DENOM));
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
denom: DENOM.to_string(),
min_bond: Uint128::new(100),
unbonding_period: Duration::Time(604800),
};
instantiate(deps.as_mut(), env.clone(), info, msg).unwrap();
// 质押 100
let user_info = mock_info("user1", &coins(100, DENOM));
let msg = ExecuteMsg::Bond {};
execute(deps.as_mut(), env.clone(), user_info.clone(), msg).unwrap();
assert!(query_member(deps.as_ref(), "user1".into(), None)
.unwrap()
.member
.is_some());
// 解锁 100(余额为 0)
let msg = ExecuteMsg::Unbond {
amount: Uint128::new(100),
};
execute(deps.as_mut(), env.clone(), user_info, msg).unwrap();
// 被移除成员列表
assert!(query_member(deps.as_ref(), "user1".into(), None)
.unwrap()
.member
.is_none());
let total = query_total_weight(deps.as_ref()).unwrap();
assert_eq!(total.weight, Uint128::zero());
}
#[test]
fn test_claim_unbonded() {
let mut deps = mock_dependencies_with_balance(&coins(0, DENOM));
let mut env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
denom: DENOM.to_string(),
min_bond: Uint128::new(100),
unbonding_period: Duration::Time(604800),
};
instantiate(deps.as_mut(), env.clone(), info, msg).unwrap();
// 质押
let user_info = mock_info("user1", &coins(500, DENOM));
let msg = ExecuteMsg::Bond {};
execute(deps.as_mut(), env.clone(), user_info.clone(), msg).unwrap();
// 解锁 200
let msg = ExecuteMsg::Unbond {
amount: Uint128::new(200),
};
execute(deps.as_mut(), env.clone(), user_info.clone(), msg).unwrap();
// 快进时间到 7 天后
env.block.time = env.block.time.plus_seconds(UNBONDING_SECONDS + 1);
// Claim
let msg = ExecuteMsg::Claim {};
let res = execute(deps.as_mut(), env.clone(), user_info.clone(), msg).unwrap();
// 验证包含转账消息
assert!(res.messages.iter().any(|m| matches!(
m.msg,
CosmosMsg::Bank(BankMsg::Send { .. })
)));
// 验证 stake 更新
let stake = query_stake_info(deps.as_ref(), "user1".into()).unwrap();
assert_eq!(stake.unbonding, Uint128::zero());
assert_eq!(stake.stake, Uint128::new(300));
}
#[test]
fn test_update_config() {
let (mut deps, env, _info) = setup_stake_test();
let admin_info = mock_info("admin", &[]);
let msg = ExecuteMsg::UpdateConfig {
min_bond: Some(Uint128::new(200)),
unbonding_period: None,
};
execute(deps.as_mut(), env.clone(), admin_info, msg).unwrap();
let config = query_config(deps.as_ref()).unwrap();
assert_eq!(config.min_bond, Uint128::new(200));
}
#[test]
fn test_wrong_denom_fails() {
let mut deps = mock_dependencies_with_balance(&coins(0, DENOM));
let env = mock_env();
let info = mock_info("admin", &[]);
let msg = InstantiateMsg {
admin: Some("admin".into()),
denom: DENOM.to_string(),
min_bond: MIN_BOND,
unbonding_period: Duration::Time(UNBONDING_SECONDS),
};
instantiate(deps.as_mut(), env.clone(), info, msg).unwrap();
// 发送错误的 denom
let user_info = mock_info("user1", &coins(500, "umsg"));
let msg = ExecuteMsg::Bond {};
let err = execute(deps.as_mut(), env, user_info, msg).unwrap_err();
assert!(matches!(err, ContractError::WrongDenom { .. }));
}
#[test]
fn test_insufficient_unbond_fails() {
let (mut deps, env, _info) = setup_stake_test();
let user_info = mock_info("user1", &coins(500, DENOM));
let msg = ExecuteMsg::Bond {};
execute(deps.as_mut(), env.clone(), user_info.clone(), msg).unwrap();
// 尝试解锁超过余额
let msg = ExecuteMsg::Unbond {
amount: Uint128::new(1000),
};
let err = execute(deps.as_mut(), env, user_info, msg).unwrap_err();
assert!(matches!(err, ContractError::InsufficientStake { .. }));
}
#[test]
fn test_claim_none_fails() {
let (mut deps, env, _info) = setup_stake_test();
let user_info = mock_info("user1", &[]);
let msg = ExecuteMsg::Claim {};
let err = execute(deps.as_mut(), env, user_info, msg).unwrap_err();
assert_eq!(err, ContractError::NoStake {});
}
}
4. CW4 查询接口
4.1 接口清单
CW4 标准查询由 cw4::Cw4QueryMsg 定义,共 5 个:
| 查询 | 返回 | 说明 |
|---|---|---|
Admin {} |
AdminResponse { admin: Option<String> } |
返回管理员地址 |
TotalWeight {} |
TotalWeightResponse { weight: Uint128 } |
返回总权重 |
Member { addr, at_height } |
MemberResponse { member: Option<Member> } |
查询单个成员 |
ListMembers { start_after, limit } |
MembersResponse { members: Vec<Member> } |
列出成员 |
Hooks {} |
HooksResponse { hooks: Vec<String> } |
返回注册的 hook 地址 |
4.2 Python SDK 查询示例
"""
CW4 查询工具 —— Python SDK 示例
使用 cosmwasm-py SDK 或直接通过 REST/LCD 调用 MSG Chain
"""
import requests
import json
from typing import Optional, List
# MSG Chain LCD 端点
LCD_ENDPOINT = "https://lcd.msgchain.org"
CONTRACT_ADDRESS = "msg1...cw4_contract..."
class Cw4Querier:
def __init__(self, contract_addr: str, lcd: str = LCD_ENDPOINT):
self.contract = contract_addr
self.lcd = lcd
def _smart_query(self, msg: dict) -> dict:
"""执行智能合约查询"""
url = f"{self.lcd}/cosmwasm/wasm/v1/contract/{self.contract}/smart/{json.dumps(msg)}"
resp = requests.get(url)
resp.raise_for_status()
return resp.json()["data"]
def query_admin(self) -> Optional[str]:
"""查询管理员"""
result = self._smart_query({"admin": {}})
return result.get("admin")
def query_total_weight(self) -> int:
"""查询总权重"""
result = self._smart_query({"total_weight": {}})
return int(result["weight"])
def query_member(self, addr: str, at_height: Optional[int] = None) -> Optional[dict]:
"""查询成员"""
msg = {"member": {"addr": addr}}
if at_height:
msg["member"]["at_height"] = at_height
result = self._smart_query(msg)
return result.get("member")
def query_list_members(
self, start_after: Optional[str] = None, limit: Optional[int] = None
) -> List[dict]:
"""列出成员"""
msg = {"list_members": {}}
if start_after:
msg["list_members"]["start_after"] = start_after
if limit:
msg["list_members"]["limit"] = limit
result = self._smart_query(msg)
return result["members"]
def query_hooks(self) -> List[str]:
"""查询 Hook"""
result = self._smart_query({"hooks": {}})
return result["hooks"]
# --- cw4_stake 扩展 ---
def query_stake_info(self, addr: str) -> dict:
"""查询质押信息(仅 cw4_stake)"""
result = self._smart_query({"stake_info": {"addr": addr}})
return result
def query_unbonding_history(self, addr: str) -> List[dict]:
"""查询解锁历史"""
result = self._smart_query({"unbonding_history": {"addr": addr}})
return result["records"]
# 使用示例
if __name__ == "__main__":
querier = Cw4Querier("msg14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9s4h3r4g")
# 1. 获取管理员
admin = querier.query_admin()
print(f"Admin: {admin}")
# 2. 获取总权重
total_weight = querier.query_total_weight()
print(f"Total Weight: {total_weight}")
# 3. 获取成员列表
members = querier.query_list_members(limit=10)
for m in members:
print(f" Member: {m['addr']}, Weight: {m['weight']}")
# 4. 查询特定成员
member = querier.query_member("msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn")
if member:
print(f"Found member with weight {member['weight']}")
else:
print("Not a member")
4.3 TypeScript/React SDK 查询
// cw4-queries.ts
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
export interface Member {
addr: string;
weight: string;
}
export interface AdminResponse {
admin?: string;
}
export interface TotalWeightResponse {
weight: string;
}
export interface MemberResponse {
member?: Member;
}
export interface MembersResponse {
members: Member[];
}
export interface HooksResponse {
hooks: string[];
}
export class Cw4Querier {
private client: CosmWasmClient;
private contract: string;
constructor(client: CosmWasmClient, contract: string) {
this.client = client;
this.contract = contract;
}
async queryAdmin(): Promise<AdminResponse> {
return this.client.queryContractSmart(this.contract, { admin: {} });
}
async queryTotalWeight(): Promise<TotalWeightResponse> {
return this.client.queryContractSmart(this.contract, { total_weight: {} });
}
async queryMember(
addr: string,
atHeight?: number
): Promise<MemberResponse> {
return this.client.queryContractSmart(this.contract, {
member: { addr, at_height: atHeight },
});
}
async queryListMembers(
startAfter?: string,
limit?: number
): Promise<MembersResponse> {
return this.client.queryContractSmart(this.contract, {
list_members: {
start_after: startAfter,
limit,
},
});
}
async queryHooks(): Promise<HooksResponse> {
return this.client.queryContractSmart(this.contract, { hooks: {} });
}
async isMember(addr: string): Promise<boolean> {
const resp = await this.queryMember(addr);
return resp.member !== undefined;
}
async getWeight(addr: string): Promise<string> {
const resp = await this.queryMember(addr);
if (!resp.member) {
throw new Error(`${addr} is not a member`);
}
return resp.member.weight;
}
}
4.4 查询效率优化
对于大成员列表的查询优化:
// query_optimized.rs
/// 分页查询——使用 cursor 模式
pub fn query_list_members_paginated(
deps: Deps,
start_after: Option<String>,
limit: Option<u32>,
) -> StdResult<MembersResponse> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize;
let start = start_after
.as_ref()
.map(|s| deps.api.addr_validate(s))
.transpose()?;
let members: StdResult<Vec<Member>> = MEMBERS
.range(deps.storage, start.as_ref(), None, Order::Ascending)
.take(limit)
.map(|r| {
let (_, member) = r?;
Ok(Member {
addr: member.addr,
weight: member.weight,
})
})
.collect();
let members = members?;
// 如果有下一个,返回分页 token
Ok(MembersResponse { members })
}
/// 批量查询多个成员
pub fn query_members_batch(
deps: Deps,
addrs: Vec<String>,
) -> StdResult<Vec<Option<Member>>> {
let results: StdResult<Vec<_>> = addrs
.into_iter()
.map(|addr| {
let addr = deps.api.addr_validate(&addr)?;
MEMBERS.may_load(deps.storage, &addr)
})
.collect();
Ok(results?)
}
4.5 at_height 历史查询
CW4 标准允许 at_height 参数,但基础实现不强制支持。启用方式:
// 使用 cw_storage_plus::SnapshotMap 替代 Map
pub const MEMBERS_HISTORY: SnapshotMap<&Addr, Member> = SnapshotMap::new(
"members",
"members__checkpoint",
"members__changlog",
Strategy::EveryBlock,
);
pub fn query_member_with_history(
deps: Deps,
addr: String,
at_height: Option<u64>,
) -> StdResult<MemberResponse> {
let addr = deps.api.addr_validate(&addr)?;
let member = match at_height {
Some(height) => MEMBERS_HISTORY.may_load_at_height(deps.storage, &addr, height)?,
None => MEMBERS_HISTORY.may_load(deps.storage, &addr)?,
};
Ok(MemberResponse { member })
}
使用 SnapshotMap 会额外消耗存储(每次成员变更都记录),适用于需要审计历史投票权的场景。
5. 与 CW3 多签集成
5.1 概述
CW3 是 CosmWasm 的多签标准。它定义了两个子类型:
| 类型 | 组需求 | 权重验证 | 场景 |
|---|---|---|---|
cw3_fixed_multisig |
必须是 cw4_group |
静态权重 | 固定签名者集合 |
cw3_flex_multisig |
cw4_group 或 cw4_stake |
动态权重 | 质押加权治理 |
集成要点:
- CW3 在
instantiate时接收一个group_addr参数 - CW3 在
propose、vote、execute时调用 CW4 查询成员权重 - CW3 通过 hook 机制 监听 CW4 成员变更
5.2 cw3_flex_multisig 集成
// cw3_flex_multisig 集成 CW4 的示例
use cosmwasm_std::{
to_json_binary, Addr, Decimal, Deps, DepsMut, Env, MessageInfo,
Order, Response, StdResult, Uint128, WasmMsg, SubMsg,
};
use cw4::{
Cw4ExecuteMsg, Cw4QueryMsg, Member,
MemberChangedHookMsg, MemberResponse, MembersResponse,
};
/// CW3 提案状态
#[cw_serde]
pub struct Proposal {
pub id: u64,
pub title: String,
pub description: String,
pub msgs: Vec<CosmosMsg>,
pub status: ProposalStatus,
pub yes_votes: Uint128,
pub no_votes: Uint128,
pub abstain_votes: Uint128,
pub veto_votes: Uint128,
pub total_weight: Uint128,
pub expires: Expiration,
pub start_height: u64,
}
/// 从 CW4 组合约查询成员权重
pub fn get_member_weight(
deps: Deps,
group_addr: &Addr,
member_addr: &Addr,
) -> StdResult<Option<Uint128>> {
let msg = Cw4QueryMsg::Member {
addr: member_addr.to_string(),
at_height: None,
};
let resp: MemberResponse = deps.querier.query_wasm_smart(group_addr, &msg)?;
Ok(resp.member.map(|m| m.weight))
}
/// 查询组总权重
pub fn get_total_weight(
deps: Deps,
group_addr: &Addr,
) -> StdResult<Uint128> {
let msg = Cw4QueryMsg::TotalWeight {};
let resp: TotalWeightResponse = deps.querier.query_wasm_smart(group_addr, &msg)?;
Ok(resp.weight)
}
/// 验证提案创建者是否为组成员
pub fn assert_group_member(
deps: Deps,
group_addr: &Addr,
sender: &Addr,
min_weight: Uint128,
) -> Result<Member, ContractError> {
let msg = Cw4QueryMsg::Member {
addr: sender.to_string(),
at_height: None,
};
let resp: MemberResponse = deps.querier.query_wasm_smart(group_addr, &msg)?;
match resp.member {
Some(member) if member.weight >= min_weight => Ok(member),
Some(_) => Err(ContractError::NotEnoughWeight {}),
None => Err(ContractError::NotMember {}),
}
}
/// 投票——权重从 CW4 获取
pub fn execute_vote(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
vote: VoteOption,
group_addr: Addr,
) -> Result<Response, ContractError> {
// 1. 获取投票者权重
let weight = get_member_weight(deps.as_ref(), &group_addr, &info.sender)?
.ok_or(ContractError::NotMember {})?;
// 2. 检查是否已经投票
let ballot_key = (proposal_id, info.sender.clone());
if BALLOTS.has(deps.storage, &ballot_key) {
return Err(ContractError::AlreadyVoted {});
}
// 3. 检查提案是否活跃
let mut prop = PROPOSALS.load(deps.storage, proposal_id)?;
if prop.status != ProposalStatus::Active {
return Err(ContractError::NotActive {});
}
if prop.expires.is_expired(&env) {
prop.status = ProposalStatus::Expired;
PROPOSALS.save(deps.storage, proposal_id, &prop)?;
return Err(ContractError::Expired {});
}
// 4. 记录投票
let ballot = Ballot {
weight,
vote: vote.clone(),
};
BALLOTS.save(deps.storage, &ballot_key, &ballot)?;
// 5. 累加投票
match vote {
VoteOption::Yes => prop.yes_votes += weight,
VoteOption::No => prop.no_votes += weight,
VoteOption::Abstain => prop.abstain_votes += weight,
VoteOption::Veto => prop.veto_votes += weight,
}
// 6. 检查是否通过
let threshold = get_threshold(&prop);
let total = get_total_weight(deps.as_ref(), &group_addr)?;
if prop.yes_votes >= threshold && prop.yes_votes > total / Decimal::percent(50) {
prop.status = ProposalStatus::Passed;
}
PROPOSALS.save(deps.storage, proposal_id, &prop)?;
Ok(Response::new()
.add_attribute("action", "vote")
.add_attribute("proposal_id", proposal_id.to_string())
.add_attribute("voter", info.sender)
.add_attribute("weight", weight.to_string())
.add_attribute("vote", format!("{:?}", vote)))
}
5.3 Hook 处理 —— 成员变更时自动关闭提案
// cw3_flex_multisig 作为 cw4 hook 接收者
/// 当 CW4 组发生成员变更时,CW3 自动处理
pub fn execute_member_changed_hook(
deps: DepsMut,
_env: Env,
info: MessageInfo,
msg: MemberChangedHookMsg,
) -> Result<Response, ContractError> {
// 验证消息发送者是已授权的 CW4 合约
let config = CONFIG.load(deps.storage)?;
if info.sender != config.group_addr {
return Err(ContractError::UnauthorizedHook {});
}
match msg.kind.as_str() {
"remove" => {
// 成员被移除 → 关闭该成员的所有活跃提案(或被重写覆盖)
let member_addr = deps.api.addr_validate(&msg.member.addr)?;
close_proposals_for_member(deps, &member_addr)?;
}
"weight_updated" => {
// 权重变化 → 检查当前活跃提案是否需要重新计算
recalculate_proposals(deps, &msg.member)?;
}
"added" => {
// 新成员加入 → 可选触发通知
// 无需自动操作
}
_ => {}
}
Ok(Response::new()
.add_attribute("action", "member_changed_hook")
.add_attribute("kind", msg.kind))
}
fn close_proposals_for_member(
deps: DepsMut,
addr: &Addr,
) -> Result<(), ContractError> {
// 遍历所有活跃提案,关闭该成员创建的提案
let proposals: Vec<u64> = PROPOSALS
.range(deps.storage, None, None, Order::Ascending)
.filter_map(|r| {
let (id, prop) = r.ok()?;
if prop.status == ProposalStatus::Active && prop.proposer == *addr {
Some(id)
} else {
None
}
})
.collect();
for id in proposals {
let mut prop = PROPOSALS.load(deps.storage, id)?;
prop.status = ProposalStatus::Rejected;
PROPOSALS.save(deps.storage, id, &prop)?;
}
Ok(())
}
5.4 Instantiate CW3 使用 CW4
// cw3_flex_multisig instantiate 使用 cw4_group
#[cw_serde]
pub struct InstantiateMsg {
/// CW4 组合约地址
pub group_addr: String,
/// 投票阈值(例如 "0.5" = 50%)
pub threshold: Threshold,
/// 最大投票时长
pub max_voting_period: Duration,
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
let group_addr = deps.api.addr_validate(&msg.group_addr)?;
// 验证 group_addr 确实是一个 CW4 合约
let admin_msg = Cw4QueryMsg::Admin {};
let admin_resp: AdminResponse = deps
.querier
.query_wasm_smart(&group_addr, &admin_msg)?;
// 保存配置
let config = Config {
group_addr: group_addr.clone(),
threshold: msg.threshold,
max_voting_period: msg.max_voting_period,
proposal_count: 0,
};
CONFIG.save(deps.storage, &config)?;
// 将本合约注册为 CW4 的 hook
let register_hook = WasmMsg::Execute {
contract_addr: group_addr.to_string(),
msg: to_json_binary(&Cw4ExecuteMsg::AddHook {
addr: _env.contract.address.to_string(),
})?,
funds: vec![],
};
Ok(Response::new()
.add_attribute("action", "instantiate")
.add_attribute("group_addr", group_addr)
.add_message(register_hook))
}
5.5 Python 集成示例
"""
CW3 + CW4 多签治理集成示例
使用 python SDK 创建提案并在 MSG Chain 上执行
"""
import hashlib
import time
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Cw4GroupConfig:
contract_addr: str
admin: str
total_weight: int
class Cw3FlexMultiSig:
"""
CW3 多签治理客户端
与 CW4 组合约集成
"""
def __init__(self, contract_addr: str, group_addr: str, lcd: str):
self.contract = contract_addr
self.group = group_addr
self.lcd = lcd
self.querier = Cw4Querier(group_addr, lcd)
def get_group_info(self) -> Cw4GroupConfig:
"""获取组信息"""
admin = self.querier.query_admin()
total_weight = self.querier.query_total_weight()
return Cw4GroupConfig(
contract_addr=self.group,
admin=admin,
total_weight=total_weight,
)
def can_propose(self, sender: str) -> bool:
"""检查用户是否有权创建提案"""
member = self.querier.query_member(sender)
return member is not None and int(member["weight"]) > 0
def propose(
self,
sender: str,
title: str,
description: str,
msgs: List[dict],
) -> dict:
"""创建提案(需要签名)"""
assert self.can_propose(sender), f"{sender} is not a group member"
msg = {
"propose": {
"title": title,
"description": description,
"msgs": msgs,
# "latest" 表示最新区块到期
"expires": {"at_height": None},
}
}
# 在实际环境中,这里需要构造签名交易
print(f"Creating proposal: {title}")
print(f"Sender: {sender}")
print(f"Message: {json.dumps(msg, indent=2)}")
return {"status": "simulated", "proposal_id": None}
def vote(self, sender: str, proposal_id: int, vote: str):
"""投票(yes / no / abstain / veto)"""
member = self.querier.query_member(sender)
assert member is not None, f"{sender} is not a member"
weight = int(member["weight"])
print(f"Voting {vote} on proposal #{proposal_id}")
print(f"Voter weight: {weight}")
msg = {
"vote": {
"proposal_id": proposal_id,
"vote": vote,
}
}
return {"status": "simulated", "vote": vote, "weight": weight}
def execute(self, sender: str, proposal_id: int):
"""执行已通过的提案"""
msg = {
"execute": {
"proposal_id": proposal_id,
}
}
print(f"Executing proposal #{proposal_id}")
return {"status": "simulated"}
# 使用示例
if __name__ == "__main__":
# MSG Chain 上的合约地址(示例)
GROUP_ADDR = "msg14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9s4h3r4g"
MULTISIG_ADDR = "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn"
multisig = Cw3FlexMultiSig(MULTISIG_ADDR, GROUP_ADDR, LCD_ENDPOINT)
# 1. 检查组信息
info = multisig.get_group_info()
print(f"Group Admin: {info.admin}")
print(f"Total Weight: {info.total_weight}")
# 2. 创建提案
multisig.propose(
sender="msg1admin...",
title="Update Treasury Parameters",
description="Adjust the reserve ratio to 20%",
msgs=[
{
"bank": {
"send": {
"to_address": "msg1receiver...",
"amount": [{"denom": "uvoice", "amount": "1000000"}],
}
}
}
],
)
# 3. 投票
multisig.vote("msg1voter1...", proposal_id=1, vote="yes")
multisig.vote("msg1voter2...", proposal_id=1, vote="yes")
# 4. 执行
multisig.execute("msg1admin...", proposal_id=1)
5.6 从 CW4 到 CW3 的完整调用链
/// 创建 CW3 提案并自动使用 CW4 成员
pub fn create_and_execute_proposal(
deps: Deps,
cw3_addr: &Addr,
cw4_addr: &Addr,
sender: &Addr,
title: &str,
description: &str,
msgs: Vec<CosmosMsg>,
) -> StdResult<ProposalStatus> {
// 1. 检查发送者是否是组成员
let member_msg = Cw4QueryMsg::Member {
addr: sender.to_string(),
at_height: None,
};
let member: MemberResponse = deps
.querier
.query_wasm_smart(cw4_addr, &member_msg)?;
let _member = member.member.ok_or_else(|| {
StdError::generic_err("Sender is not a group member")
})?;
// 2. 获取当前总权重
let tw_msg = Cw4QueryMsg::TotalWeight {};
let tw: TotalWeightResponse = deps
.querier
.query_wasm_smart(cw4_addr, &tw_msg)?;
// 3. 调用 CW3 创建提案
let propose_msg = WasmMsg::Execute {
contract_addr: cw3_addr.to_string(),
msg: to_json_binary(&cw3::Cw3ExecuteMsg::Propose {
title: title.to_string(),
description: description.to_string(),
msgs,
latest: None, // 使用默认到期
})?,
funds: vec![],
};
// 模拟执行——实际场景需要广播交易
println!("Proposal created with total weight: {}", tw.weight);
Ok(ProposalStatus::Active)
}
5.7 多组聚合
对于需要多组联合决策的场景:
/// 多组聚合——从多个 CW4 组查询权重并求和
pub fn aggregated_weight(
deps: Deps,
group_addrs: &[Addr],
member_addr: &Addr,
) -> StdResult<Uint128> {
let mut total = Uint128::zero();
for group in group_addrs {
let msg = Cw4QueryMsg::Member {
addr: member_addr.to_string(),
at_height: None,
};
let resp: MemberResponse = deps.querier.query_wasm_smart(group, &msg)?;
if let Some(member) = resp.member {
total = total.checked_add(member.weight)?;
}
}
Ok(total)
}
6. 前端组件
6.1 React 成员列表组件
// Cw4MemberList.tsx
import React, { useEffect, useState } from "react";
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { Cw4Querier, Member } from "./cw4-queries";
interface Props {
rpcEndpoint: string;
contractAddress: string;
pageSize?: number;
}
export const Cw4MemberList: React.FC<Props> = ({
rpcEndpoint,
contractAddress,
pageSize = 20,
}) => {
const [members, setMembers] = useState<Member[]>([]);
const [totalWeight, setTotalWeight] = useState<string>("0");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
useEffect(() => {
loadMembers();
}, [contractAddress, rpcEndpoint, page]);
const loadMembers = async () => {
try {
setLoading(true);
const client = await CosmWasmClient.connect(rpcEndpoint);
const querier = new Cw4Querier(client, contractAddress);
const [tw, memberList] = await Promise.all([
querier.queryTotalWeight(),
querier.queryListMembers(
page > 0 ? members[members.length - 1]?.addr : undefined,
pageSize
),
]);
setTotalWeight(tw.weight);
setMembers((prev) =>
page > 0 ? [...prev, ...memberList.members] : memberList.members
);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (error) {
return <div className="error">Error: {error}</div>;
}
return (
<div className="cw4-member-list">
<div className="header">
<h2>Group Members</h2>
<span className="total-weight">
Total Weight: {totalWeight}
</span>
</div>
<table className="member-table">
<thead>
<tr>
<th>Address</th>
<th>Weight</th>
<th>Voting Power %</th>
</tr>
</thead>
<tbody>
{members.map((member) => (
<tr key={member.addr}>
<td className="addr">{member.addr}</td>
<td className="weight">{member.weight}</td>
<td className="power">
{totalWeight !== "0"
? (
(Number(member.weight) / Number(totalWeight)) *
100
).toFixed(2) + "%"
: "0%"}
</td>
</tr>
))}
</tbody>
</table>
{loading && <div className="loading">Loading...</div>}
{!loading && members.length > 0 && (
<button onClick={() => setPage((p) => p + 1)} className="load-more">
Load More
</button>
)}
{!loading && members.length === 0 && (
<div className="empty">No members found</div>
)}
</div>
);
};
6.2 管理员面板组件
// Cw4AdminPanel.tsx
import React, { useEffect, useState } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { GasPrice } from "@cosmjs/stargate";
interface Props {
rpcEndpoint: string;
contractAddress: string;
sender: string;
signingClient: SigningCosmWasmClient;
}
export const Cw4AdminPanel: React.FC<Props> = ({
rpcEndpoint,
contractAddress,
sender,
signingClient,
}) => {
const [admin, setAdmin] = useState<string | null>(null);
const [newAdmin, setNewAdmin] = useState("");
const [batchAdd, setBatchAdd] = useState("");
const [batchRemove, setBatchRemove] = useState("");
const [txResult, setTxResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadAdmin();
}, [contractAddress]);
const loadAdmin = async () => {
const client = await CosmWasmClient.connect(rpcEndpoint);
const resp: { admin?: string } = await client.queryContractSmart(
contractAddress,
{ admin: {} }
);
setAdmin(resp.admin ?? null);
};
const isAdmin = admin === sender;
const handleUpdateAdmin = async () => {
if (!newAdmin) return;
try {
setError(null);
const tx = await signingClient.execute(
sender,
contractAddress,
{ update_admin: { admin: newAdmin } },
"auto",
"Update admin"
);
setTxResult(`Admin updated to ${newAdmin}. Tx: ${tx.transactionHash}`);
setAdmin(newAdmin);
} catch (err: any) {
setError(err.message);
}
};
const handleUpdateMembers = async () => {
try {
setError(null);
// 批量添加格式: addr1:weight1,addr2:weight2
const addEntries = batchAdd
.split(",")
.filter(Boolean)
.map((s) => {
const [addr, weight] = s.split(":");
return { addr: addr.trim(), weight: weight?.trim() || "1" };
});
const removeEntries = batchRemove
.split(",")
.filter(Boolean)
.map((s) => s.trim());
const tx = await signingClient.execute(
sender,
contractAddress,
{
update_members: {
add: addEntries,
remove: removeEntries,
},
},
"auto",
"Update members"
);
setTxResult(
`Members updated. Added: ${addEntries.length}, Removed: ${removeEntries.length}. Tx: ${tx.transactionHash}`
);
} catch (err: any) {
setError(err.message);
}
};
if (!isAdmin) {
return (
<div className="admin-panel">
<h3>Admin Panel</h3>
<p>Current admin: {admin}</p>
<p className="not-admin">You are not the admin</p>
</div>
);
}
return (
<div className="admin-panel">
<h3>Admin Panel</h3>
<p>Current admin: {admin}</p>
<div className="section">
<h4>Update Admin</h4>
<input
type="text"
value={newAdmin}
onChange={(e) => setNewAdmin(e.target.value)}
placeholder="New admin address"
/>
<button onClick={handleUpdateAdmin}>Update Admin</button>
</div>
<div className="section">
<h4>Batch Add Members</h4>
<textarea
value={batchAdd}
onChange={(e) => setBatchAdd(e.target.value)}
placeholder="Format: addr1:weight1,addr2:weight2"
rows={3}
/>
</div>
<div className="section">
<h4>Batch Remove Members</h4>
<textarea
value={batchRemove}
onChange={(e) => setBatchRemove(e.target.value)}
placeholder="Format: addr1,addr2,addr3"
rows={2}
/>
<button onClick={handleUpdateMembers}>Update Members</button>
</div>
{txResult && <div className="success">{txResult}</div>}
{error && <div className="error">{error}</div>}
</div>
);
};
6.3 Staking 面板 (cw4_stake)
// Cw4StakePanel.tsx
import React, { useState } from "react";
import { coins } from "@cosmjs/stargate";
import type { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
interface Props {
contractAddress: string;
sender: string;
signingClient: SigningCosmWasmClient;
denom: string;
}
export const Cw4StakePanel: React.FC<Props> = ({
contractAddress,
sender,
signingClient,
denom,
}) => {
const [bondAmount, setBondAmount] = useState("");
const [unbondAmount, setUnbondAmount] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleBond = async () => {
if (!bondAmount || parseInt(bondAmount) <= 0) return;
try {
setError(null);
setStatus("Bonding...");
const funds = coins(bondAmount, denom);
const tx = await signingClient.execute(
sender,
contractAddress,
{ bond: {} },
"auto",
`Bond ${bondAmount} ${denom}`,
funds
);
setStatus(`Successfully bonded ${bondAmount} ${denom}`);
setBondAmount("");
} catch (err: any) {
setError(err.message);
setStatus(null);
}
};
const handleUnbond = async () => {
if (!unbondAmount || parseInt(unbondAmount) <= 0) return;
try {
setError(null);
setStatus("Unbonding...");
const tx = await signingClient.execute(
sender,
contractAddress,
{ unbond: { amount: unbondAmount } },
"auto",
`Unbond ${unbondAmount} ${denom}`
);
setStatus(
`Unbond started for ${unbondAmount} ${denom}. Claim after unbonding period.`
);
setUnbondAmount("");
} catch (err: any) {
setError(err.message);
setStatus(null);
}
};
const handleClaim = async () => {
try {
setError(null);
setStatus("Claiming...");
const tx = await signingClient.execute(
sender,
contractAddress,
{ claim: {} },
"auto",
"Claim unbonded tokens"
);
setStatus("Claim successful!");
} catch (err: any) {
setError(err.message);
setStatus(null);
}
};
return (
<div className="cw4-stake-panel">
<h3>Stake Management</h3>
<div className="section">
<h4>Bond (Stake)</h4>
<input
type="number"
value={bondAmount}
onChange={(e) => setBondAmount(e.target.value)}
placeholder={`Amount in ${denom}`}
min="0"
/>
<button onClick={handleBond} disabled={!bondAmount}>
Bond
</button>
</div>
<div className="section">
<h4>Unbond (Start Unlock)</h4>
<input
type="number"
value={unbondAmount}
onChange={(e) => setUnbondAmount(e.target.value)}
placeholder={`Amount in ${denom}`}
min="0"
/>
<button onClick={handleUnbond} disabled={!unbondAmount}>
Unbond
</button>
</div>
<div className="section">
<h4>Claim Unbonded</h4>
<button onClick={handleClaim}>Claim Matured Tokens</button>
</div>
{status && <div className="status">{status}</div>}
{error && <div className="error">{error}</div>}
</div>
);
};
6.4 Hook 列表组件
// Cw4HookList.tsx
import React, { useEffect, useState } from "react";
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
interface Props {
rpcEndpoint: string;
contractAddress: string;
isAdmin: boolean;
signingClient?: any;
sender?: string;
}
export const Cw4HookList: React.FC<Props> = ({
rpcEndpoint,
contractAddress,
isAdmin,
signingClient,
sender,
}) => {
const [hooks, setHooks] = useState<string[]>([]);
const [newHook, setNewHook] = useState("");
useEffect(() => {
loadHooks();
}, [contractAddress]);
const loadHooks = async () => {
const client = await CosmWasmClient.connect(rpcEndpoint);
const resp: { hooks: string[] } = await client.queryContractSmart(
contractAddress,
{ hooks: {} }
);
setHooks(resp.hooks);
};
const handleAddHook = async () => {
if (!newHook || !signingClient) return;
await signingClient.execute(
sender,
contractAddress,
{ add_hook: { addr: newHook } },
"auto"
);
setNewHook("");
await loadHooks();
};
const handleRemoveHook = async (addr: string) => {
if (!signingClient) return;
await signingClient.execute(
sender,
contractAddress,
{ remove_hook: { addr } },
"auto"
);
await loadHooks();
};
return (
<div className="cw4-hook-list">
<h3>Registered Hooks</h3>
{hooks.length === 0 && <p>No hooks registered</p>}
<ul>
{hooks.map((hook) => (
<li key={hook}>
<code>{hook}</code>
{isAdmin && (
<button onClick={() => handleRemoveHook(hook)}>Remove</button>
)}
</li>
))}
</ul>
{isAdmin && (
<div className="add-hook">
<input
type="text"
value={newHook}
onChange={(e) => setNewHook(e.target.value)}
placeholder="Hook contract address"
/>
<button onClick={handleAddHook}>Add Hook</button>
</div>
)}
</div>
);
};
7. 部署与测试
7.1 编译合约
# 1. 克隆 cw-plus 仓库
git clone https://github.com/CosmWasm/cw-plus.git
cd cw-plus
# 2. 编译 cw4_group
cargo wasm -p cw4-group
# 输出: target/wasm32-unknown-unknown/release/cw4_group.wasm
# 3. 编译 cw4_stake
cargo wasm -p cw4-stake
# 输出: target/wasm32-unknown-unknown/release/cw4_stake.wasm
# 4. 优化(减小体积,推荐)
docker run --rm -v "$(pwd)":/code \
--mount type=volume,source="$(basename "$(pwd)")_cache",target=/target \
--mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \
cosmwasm/optimizer:0.15.1
# 5. 检查合约大小
ls -lh artifacts/
# cw4_group.wasm ~170 KB
# cw4_stake.wasm ~200 KB
7.2 部署 cw4_group(静态组)
# MSG Chain CLI 部署
# 1. 上传合约代码
msgd tx wasm store artifacts/cw4_group.wasm \
--from admin \
--gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1 \
--node https://rpc.msgchain.org:443
# 返回 code_id: 42
# 2. Instantiate cw4_group
INIT='{
"admin": "msg1admin_address...",
"members": [
{"addr": "msg1member1...", "weight": "10"},
{"addr": "msg1member2...", "weight": "20"},
{"addr": "msg1member3...", "weight": "30"}
]
}'
msgd tx wasm instantiate 42 "$INIT" \
--from admin \
--label "dao-council-group" \
--gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1 \
--node https://rpc.msgchain.org:443 \
--admin msg1admin_address...
# 返回 contract_address: msg1group_contract...
# 3. 验证部署
msgd query wasm contract msg1group_contract...
msgd query wasm contract-state smart msg1group_contract... '{"admin":{}}'
msgd query wasm contract-state smart msg1group_contract... '{"total_weight":{}}'
msgd query wasm contract-state smart msg1group_contract... '{"list_members":{"limit":10}}'
7.3 部署 cw4_stake(动态组)
# 1. 上传
msgd tx wasm store artifacts/cw4_stake.wasm \
--from admin \
--gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1
# code_id: 43
# 2. Instantiate
INIT='{
"admin": "msg1admin_address...",
"denom": "uvoice",
"min_bond": "1000000",
"unbonding_period": 604800
}'
msgd tx wasm instantiate 43 "$INIT" \
--from admin \
--label "stake-weighted-group" \
--gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1 \
--admin msg1admin_address...
# contract_address: msg1stake_contract...
# 3. 用户质押
msgd tx wasm execute msg1stake_contract... '{"bond":{}}' \
--from user1 \
--amount 5000000uvoice \
--gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1
# 4. 查询成员
msgd query wasm contract-state smart msg1stake_contract... \
'{"member":{"addr":"msg1user1..."}}'
7.4 部署 cw3_flex_multisig(使用 cw4_group)
# 1. 上传 cw3_flex_multisig 代码
msgd tx wasm store artifacts/cw3_flex_multisig.wasm \
--from admin --gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1
# code_id: 44
# 2. Instantiate——引用 CW4 组
INIT='{
"group_addr": "msg1group_contract...",
"threshold": {"absolute_count":{"weight":30}},
"max_voting_period": {"time":604800}
}'
msgd tx wasm instantiate 44 "$INIT" \
--from admin \
--label "dao-multisig" \
--gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1 \
--admin msg1admin_address...
# 3. 确认 CW3 自动注册为 CW4 的 hook
msgd query wasm contract-state smart msg1group_contract... '{"hooks":{}}'
# hooks 包含 cw3 合约地址
7.5 交互测试
# 1. 查询组信息
TOTAL_WEIGHT=$(msgd query wasm contract-state smart msg1group_contract... \
'{"total_weight":{}}' | jq -r '.data.weight')
echo "Total weight: $TOTAL_WEIGHT"
# 2. 创建提案(通过 CW3)
msgd tx wasm execute msg1multisig_contract... \
'{
"propose":{
"title":"Test Proposal",
"description":"Testing CW4-CW3 integration",
"msgs":[],
"latest":{"at_height":1000000}
}
}' \
--from member1 --gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1
# 3. 投票
msgd tx wasm execute msg1multisig_contract... \
'{"vote":{"proposal_id":1,"vote":"yes"}}' \
--from member2 --gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1
# 4. 执行提案
msgd tx wasm execute msg1multisig_contract... \
'{"execute":{"proposal_id":1}}' \
--from member1 --gas auto --gas-prices 1000000000attoMSG --gas-adjustment 1.3 \
--chain-id msg-chain-1
7.6 Python 集成测试
"""
CW4 集成测试套件
运行前需要部署好合约
"""
import subprocess
import json
import time
import requests
from typing import Optional
class Cw4IntegrationTest:
"""CW4 端到端集成测试"""
def __init__(
self,
msgd_bin: str = "msgd",
chain_id: str = "msg-chain-1",
node: str = "https://rpc.msgchain.org:443",
from_key: str = "admin",
):
self.msgd = msgd_bin
self.chain_id = chain_id
self.node = node
self.from_key = from_key
self.group_addr: Optional[str] = None
self.stake_addr: Optional[str] = None
def _run(self, cmd: list, input_data: Optional[str] = None) -> dict:
"""执行 msgd 命令"""
full_cmd = cmd + [
"--chain-id", self.chain_id,
"--node", self.node,
"--gas", "auto",
"--gas-prices", "1000000000attoMSG",
"--gas-adjustment", "1.3",
"--output", "json",
"-y",
]
result = subprocess.run(
full_cmd,
input=input_data,
capture_output=True,
text=True,
timeout=60,
)
return json.loads(result.stdout)
def deploy_group(self, wasm_path: str, admin: str, members: list) -> str:
"""部署 cw4_group"""
# store
store_res = self._run([
self.msgd, "tx", "wasm", "store", wasm_path,
"--from", self.from_key,
])
code_id = store_res["logs"][0]["events"][0]["attributes"][2]["value"]
print(f"Stored cw4_group with code_id: {code_id}")
# instantiate
init_msg = json.dumps({
"admin": admin,
"members": members,
})
instantiate_res = self._run([
self.msgd, "tx", "wasm", "instantiate", code_id, init_msg,
"--from", self.from_key,
"--label", "test-cw4-group",
"--admin", admin,
])
self.group_addr = instantiate_res["logs"][0]["events"][0]["attributes"][0]["value"]
print(f"cw4_group deployed at: {self.group_addr}")
return self.group_addr
def deploy_stake(
self,
wasm_path: str,
admin: str,
denom: str,
min_bond: str,
unbonding_period: int,
) -> str:
"""部署 cw4_stake"""
store_res = self._run([
self.msgd, "tx", "wasm", "store", wasm_path,
"--from", self.from_key,
])
code_id = store_res["logs"][0]["events"][0]["attributes"][2]["value"]
print(f"Stored cw4_stake with code_id: {code_id}")
init_msg = json.dumps({
"admin": admin,
"denom": denom,
"min_bond": min_bond,
"unbonding_period": unbonding_period,
})
instantiate_res = self._run([
self.msgd, "tx", "wasm", "instantiate", code_id, init_msg,
"--from", self.from_key,
"--label", "test-cw4-stake",
"--admin", admin,
])
self.stake_addr = instantiate_res["logs"][0]["events"][0]["attributes"][0]["value"]
print(f"cw4_stake deployed at: {self.stake_addr}")
return self.stake_addr
def query_smart(self, contract: str, query: dict) -> dict:
"""智能合约查询"""
query_str = json.dumps(query)
resp = self._run([
self.msgd, "query", "wasm", "contract-state", "smart",
contract, query_str,
])
return resp["data"]
def execute(self, contract: str, msg: dict, from_key: str, funds: Optional[str] = None):
"""Execute 合约方法"""
msg_str = json.dumps(msg)
cmd = [
self.msgd, "tx", "wasm", "execute", contract, msg_str,
"--from", from_key,
]
if funds:
cmd += ["--amount", funds]
return self._run(cmd)
def test_group_lifecycle(self):
"""测试 cw4_group 完整生命周期"""
print("\n=== Testing cw4_group Lifecycle ===")
members = [
{"addr": "msg1member1...", "weight": "10"},
{"addr": "msg1member2...", "weight": "20"},
]
self.deploy_group("artifacts/cw4_group.wasm", self.from_key, members)
# 查询成员
members_resp = self.query_smart(self.group_addr, {"list_members": {"limit": 5}})
assert len(members_resp["members"]) == 2
print(f"✓ Members count: {len(members_resp['members'])}")
# 添加成员
self.execute(self.group_addr, {
"update_members": {
"add": [{"addr": "msg1member3...", "weight": "30"}],
"remove": [],
}
}, self.from_key)
members_resp = self.query_smart(self.group_addr, {"list_members": {"limit": 5}})
assert len(members_resp["members"]) == 3
print("✓ Added member successfully")
# 查询总权重
tw = self.query_smart(self.group_addr, {"total_weight": {}})
assert tw["weight"] == "60"
print(f"✓ Total weight: {tw['weight']}")
print("✓ cw4_group lifecycle test PASSED")
def test_stake_lifecycle(self):
"""测试 cw4_stake 完整生命周期"""
print("\n=== Testing cw4_stake Lifecycle ===")
self.deploy_stake(
"artifacts/cw4_stake.wasm",
self.from_key,
"uvoice",
"1000000",
604800,
)
# 没有成员
members = self.query_smart(self.stake_addr, {"list_members": {"limit": 5}})
assert len(members["members"]) == 0
print(f"✓ No members initially: {len(members['members'])}")
print("✓ cw4_stake lifecycle test PASSED (bond/unbond tested in unit tests)")
def run_all(self):
"""运行所有集成测试"""
print("=== CW4 Integration Tests ===")
print(f"Chain: {self.chain_id}")
print(f"Node: {self.node}")
try:
self.test_group_lifecycle()
self.test_stake_lifecycle()
print("\n=== ALL TESTS PASSED ===")
except Exception as e:
print(f"\n!!! TEST FAILED: {e}")
raise
if __name__ == "__main__":
test = Cw4IntegrationTest()
test.run_all()
7.7 TypeScript 集成测试
// cw4.integration.test.ts
import {
CosmWasmClient,
SigningCosmWasmClient,
} from "@cosmjs/cosmwasm-stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { GasPrice } from "@cosmjs/stargate";
import { Cw4Querier } from "./cw4-queries";
import fs from "fs";
const RPC = "https://rpc.msgchain.org:443";
const CHAIN_ID = "msg-chain-1";
const PREFIX = "msg";
async function deployContract(
client: SigningCosmWasmClient,
deployer: string,
wasmPath: string,
initMsg: object,
label: string
): Promise<string> {
const wasm = fs.readFileSync(wasmPath);
const uploadRes = await client.upload(deployer, wasm, "auto");
console.log(`Uploaded code ID: ${uploadRes.codeId}`);
const instantiateRes = await client.instantiate(
deployer,
uploadRes.codeId,
initMsg,
label,
"auto"
);
console.log(`Contract deployed: ${instantiateRes.contractAddress}`);
return instantiateRes.contractAddress;
}
describe("CW4 Integration Tests", () => {
let client: CosmWasmClient;
let signingClient: SigningCosmWasmClient;
let deployer: string;
let groupAddr: string;
beforeAll(async () => {
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(
"your mnemonic here",
{ prefix: PREFIX }
);
deployer = (await wallet.getAccounts())[0].address;
signingClient = await SigningCosmWasmClient.connectWithSigner(
RPC,
wallet,
{ gasPrice: GasPrice.fromString("1000000000attoMSG") }
);
client = await CosmWasmClient.connect(RPC);
});
it("should deploy cw4_group", async () => {
groupAddr = await deployContract(
signingClient,
deployer,
"./artifacts/cw4_group.wasm",
{
admin: deployer,
members: [
{ addr: deployer, weight: "10" },
{
addr: "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwgvfwn",
weight: "20",
},
],
},
"test-cw4-group"
);
expect(groupAddr).toBeDefined();
});
it("should query total weight", async () => {
const querier = new Cw4Querier(client, groupAddr);
const tw = await querier.queryTotalWeight();
expect(tw.weight).toBe("30");
});
it("should update members", async () => {
await signingClient.execute(
deployer,
groupAddr,
{
update_members: {
add: [{ addr: "msg1newmember...", weight: "40" }],
remove: [],
},
},
"auto"
);
const querier = new Cw4Querier(client, groupAddr);
const members = await querier.queryListMembers();
expect(members.members).toHaveLength(3);
const tw = await querier.queryTotalWeight();
expect(tw.weight).toBe("70");
});
it("should reject non-admin updates", async () => {
const nonAdmin = "msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2nc07r";
await expect(
signingClient.execute(
nonAdmin,
groupAddr,
{
update_members: {
add: [],
remove: [deployer],
},
},
"auto"
)
).rejects.toThrow();
});
it("should query admin and hooks", async () => {
const querier = new Cw4Querier(client, groupAddr);
const admin = await querier.queryAdmin();
expect(admin.admin).toBe(deployer);
const hooks = await querier.queryHooks();
expect(hooks.hooks).toEqual([]);
});
it("should deploy CW3 with CW4 group", async () => {
const multisigAddr = await deployContract(
signingClient,
deployer,
"./artifacts/cw3_flex_multisig.wasm",
{
group_addr: groupAddr,
threshold: { absolute_count: { weight: 15 } },
max_voting_period: { time: 604800 },
},
"test-cw3-flex"
);
expect(multisigAddr).toBeDefined();
});
});
7.8 Gas 费用估算
| 操作 | Gas 消耗 (est.) | 费用 (1000000000attoMSG) |
|---|---|---|
| Upload cw4_group.wasm | ~3,000,000 | 75 uvoice |
| Instantiate cw4_group (10 members) | ~500,000 | 12.5 uvoice |
| UpdateMembers (add 1) | ~200,000 | 5 uvoice |
| UpdateMembers (remove 1) | ~150,000 | 3.75 uvoice |
| Bond (cw4_stake) | ~300,000 | 7.5 uvoice |
| Unbond (cw4_stake) | ~350,000 | 8.75 uvoice |
| Claim (cw4_stake) | ~400,000 | 10 uvoice |
| Query (smart) | ~10,000 | 0.25 uvoice |
7.9 常见问题排查
# 问题 1: Instantiate 失败 "address not valid"
# 原因:地址不是 MSG Chain 格式(缺少 msg 前缀)
# 解决:确保所有地址以 msg 开头
INIT='{
"admin": "msg1correct_prefix..."
}'
# 问题 2: "Unauthorized"
# 原因:发送者不是合约 admin
# 解决:检查合约 admin
msgd query wasm contract-state smart CONTRACT '{"admin":{}}'
# 问题 3: "Group is immutable"
# 原因:合约 admin 为 None(不可变组)
# 解决:部署时传入 admin 参数
# 问题 4: "InsufficientStake"
# 原因:Unbond 数量超过实际质押
# 解决:先查询 stake_info
msgd query wasm contract-state smart STAKE_CONTRACT \
'{"stake_info":{"addr":"USER_ADDR"}}'
# 问题 5: Gas 耗尽
# 原因:合约操作较复杂
# 解决:增加 --gas-adjustment 值
--gas-adjustment 2.0
# 问题 6: Hook 回调失败
# 原因:Hook 合约不存在或逻辑错误
# 解决:检查 Hook 合约状态,或移除问题 Hook
msgd tx wasm execute GROUP_CONTRACT '{"remove_hook":{"addr":"BAD_HOOK"}}' \
--from admin
7.10 生产部署 Checklist
□ cw4_group 编译并优化(cosmwasm/optimizer)
□ 合约大小 < 800 KB(MSG Chain 限制)
□ code_id 确认(testnet 上先部署)
□ Instantiate 使用多签地址作为 admin
□ 成员地址全部校验(msg 前缀)
□ cw4_stake min_bond 设置合理(防粉尘)
□ unbonding_period 符合治理需求(通常 7-14 天)
□ cw3 已注册为 cw4 hook
□ 前端组件已连接 RPC 端点
□ 单元测试 + 集成测试通过
□ 监控合约事件(MemberChanged)
□ 备份 Instantiate 参数和合约地址
附录
A. 依赖版本
# Cargo.toml
[package]
name = "cw4-group-msgchain"
version = "1.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cosmwasm-schema = "1.5"
cosmwasm-std = { version = "1.5", features = ["staking"] }
cosmwasm-storage = "1.5"
cw-storage-plus = "1.2"
cw-utils = "1.0"
cw-controllers = "1.1"
cw4 = "1.1"
schemars = "0.8"
serde = { version = "1.0", default-features = false, features = ["derive"] }
thiserror = { version = "1.0" }
[dev-dependencies]
cw-multi-test = "1.1"
B. Cargo 特性
[features]
default = []
library = []
C. MSG Chain 相关资源
| 资源 | URL |
|---|---|
| MSG Chain RPC | https://rpc.msgchain.org:443 |
| MSG Chain LCD | https://lcd.msgchain.org |
| 水龙头 | https://faucet.msgchain.org |
| 区块浏览器 | https://explorer.msgchain.org |
| cw-plus 源码 | https://github.com/CosmWasm/cw-plus |
| CW4 标准文档 | https://github.com/CosmWasm/cw-plus/blob/main/packages/cw4/README.md |
D. 术语对照表
| 英文 | 中文 |
|---|---|
| CW4 | 组成员资格标准 |
| Member Group | 成员组 |
| Weight | 权重/投票权 |
| Static Group | 静态组 (cw4_group) |
| Dynamic Group | 动态组 (cw4_stake) |
| Group Admin | 组管理员 |
| Bond | 质押 |
| Unbond | 解锁 |
| Claim | 提取 |
| Unbonding Period | 解锁期 |
| Min Bond | 最小质押量 |
| Hook | 回调合约 |
| Snapshot | 快照 |
| Denom | 代币面额 |
| Total Weight | 总权重 |
| Vote Weight | 投票权重 |
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
