dApp Docs/CW4(Group)合约实现
Development reference. Not independently verified for production.

CW4 (Member Group) 合约实现指南 —— MSG Chain

版本: v1.0
目标链: msg-chain-1 (bech32 前缀: msg)
主网状态: No-Go
标准: CosmWasm CW4 Group / Stake
适用场景: 多签 (cw3)、DAO 治理、子密钥权限管理


目录

  1. 概述
  2. cw4_group 静态组实现
  3. cw4_stake 动态组实现
  4. CW4 查询接口
  5. 与 CW3 多签集成
  6. 前端组件
  7. 部署与测试

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 集成要点

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 安全性考虑


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 的动态变体,成员资格与权重完全由质押金额决定。

核心逻辑:

  1. 用户通过 Bond{} 消息发送代币到合约
  2. 合约记录用户质押量,权重 = 质押量
  3. 用户通过 Unbond{} 消息解锁代币
  4. 解锁有锁定期(如 7 天),到期后可提取
  5. 如果解锁后余额 < 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 动态权重 质押加权治理

集成要点:

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/