MSG Chain 多签钱包合约实现指南
主网状态: No-Go
目录
1. 多签概述
1.1 什么是多重签名钱包
多重签名(Multi-Signature, Multi-Sig)是一种需要多个私钥持有者共同签署才能执行交易的钱包机制。与传统的单签名钱包(一个私钥即可控制所有资产)不同,多签钱包采用 t-of-n 模型,即 n 个授权签名者中,至少需要 t 个签名才能执行操作。
核心公式:
threshold (t) ≤ total_weight (n) 且 threshold ≥ 1
例如,3/5 多签表示 5 个签名者中至少需要 3 人同意才能执行任何操作。
1.2 CW3 标准概述
CosmWasm 的 CW3 标准定义了多签合约的通用接口。它建立在 CW1(代理合约)基础之上,扩展了投票和治理功能。CW3 标准包含以下核心消息类型:
| 消息 | 描述 |
|---|---|
Propose{title, description, msgs, earliest} |
创建提案 |
Vote{proposal_id, vote} |
对提案投票(Yes/No/Veto/Abstain) |
Execute{proposal_id} |
执行已通过的提案 |
Close{proposal_id} |
关闭已过期或失败的提案 |
ListVoters{} |
列出所有投票者 |
ListProposals{} |
列出所有提案 |
ReverseProposals{} |
反向列出提案 |
Voter{address} |
查询某个投票者信息 |
Proposal{proposal_id} |
查询提案详情 |
1.3 Fixed vs Flexible Multisig
CW3 标准提供了两种主要实现:
| 特性 | cw3_fixed_multisig | cw3_flex_multisig |
|---|---|---|
| 签名者集合 | 实例化时固定,不可更改 | 可通过提案动态增删 |
| 阈值 | 实例化时固定 | 可通过提案更改 |
| 适用场景 | 团队金库、DAO 财库 | 需要成员变动的组织 |
| 实现复杂度 | 低 | 中 |
| Gas 消耗 | 低 | 中等 |
1.4 MSG Chain 上的多签应用场景
DAO 财库管理
dao_governance_v1 合约使用 CW3 作为财库合约,确保治理提案通过后,财库资金只能经由多签投票执行。这实现了"治理投票 + 多签执行"的双层安全架构。
DAO 治理提案 → 投票通过 → 多签财库执行 → 资金转移
团队资金管理
项目方使用多签钱包管理:
- 开发资金(3/5 多签)
- 生态系统基金(4/7 多签)
- 运营开销(2/3 多签,小额快速批准)
高价值操作保护
- 合约升级(4/6 多签)
- 跨链桥管理(5/8 多签 + 时间锁)
- 协议参数调整(3/5 多签)
- 紧急暂停(2/4 多签,快速响应)
Agent MPC 钱包对比
| 特性 | CW3 多签 | Agent MPC 钱包 |
|---|---|---|
| 签名方式 | 链上投票聚合 | off-chain MPC 计算 |
| 签名者数量 | 3-10 较优 | 2-16 |
| 交易类型 | 任意 CosmWasm 消息 | 任意 CosmWasm 消息 |
| 隐私性 | 公开投票记录 | 签名过程不公开 |
| Gas 成本 | 多笔投票交易 | 单笔交易 |
| 灵活性 | 投票规则可编程 | 策略引擎驱动 |
2. 合约完整实现 — cw3_fixed_multisig
2.1 存储布局
use cosmwasm_std::{
Addr, Binary, BlockInfo, CosmosMsg, Decimal, Empty, QuerierWrapper,
Storage, Timestamp, Uint128,
};
use cw_storage_plus::{Item, Map, SnapshotMap, Strategy};
use cw_utils::{Duration, Expiration, Threshold};
// 合约配置
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
pub threshold: Threshold,
pub max_voting_period: Duration,
pub total_weight: Uint128,
}
// 投票者信息
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Voter {
pub addr: String,
pub weight: Uint128,
}
// 提案状态
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum ProposalStatus {
Pending,
Open,
Passed,
Rejected,
Executed,
Closed,
Expired,
}
// 投票类型
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum VoteType {
Yes,
No,
Veto,
Abstain,
}
// 提案结构
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Proposal {
pub id: u64,
pub title: String,
pub description: String,
pub msgs: Vec<CosmosMsg<Empty>>,
pub status: ProposalStatus,
pub expires: Expiration,
pub threshold: Threshold,
pub total_weight: Uint128,
pub yes_weight: Uint128,
pub no_weight: Uint128,
pub veto_weight: Uint128,
pub abstain_weight: Uint128,
pub proposer: Addr,
pub created: Timestamp,
pub executed_at: Option<Timestamp>,
}
// 投票记录
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Vote {
pub proposal_id: u64,
pub voter: Addr,
pub vote: VoteType,
pub weight: Uint128,
pub voted_at: Timestamp,
}
// 存储 Key
pub const CONFIG: Item<Config> = Item::new("config");
pub const VOTERS: Map<&Addr, Voter> = Map::new("voters");
pub const PROPOSAL_COUNT: Item<u64> = Item::new("proposal_count");
pub const PROPOSALS: Map<u64, Proposal> = Map::new("proposals");
pub const VOTES: Map<(u64, &Addr), Vote> = Map::new("votes");
2.2 完整合约实现
use cosmwasm_std::{
attr, entry_point, to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut,
Empty, Env, MessageInfo, Order, Response, StdError, StdResult, Uint128,
};
use cw2::set_contract_version;
use cw_utils::{Expiration, Threshold, ThresholdResponse};
const CONTRACT_NAME: &str = "crates.io:cw3-fixed-multisig";
const CONTRACT_VERSION: &str = "1.0.0";
const MAX_PROPOSAL_SIZE: u64 = 30;
const MAX_DESC_LENGTH: u64 = 1024;
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> StdResult<Response> {
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
let total_weight = msg
.signers
.iter()
.fold(Uint128::zero(), |acc, v| acc + v.weight);
let cfg = Config {
threshold: msg.threshold,
max_voting_period: msg.max_voting_period,
total_weight,
};
CONFIG.save(deps.storage, &cfg)?;
for voter in msg.signers.iter() {
let voter_addr = deps.api.addr_validate(&voter.addr)?;
VOTERS.save(
deps.storage,
&voter_addr,
&Voter {
addr: voter.addr.clone(),
weight: voter.weight,
},
)?;
}
PROPOSAL_COUNT.save(deps.storage, &0u64)?;
Ok(Response::default()
.add_attribute("method", "instantiate")
.add_attribute("total_weight", total_weight)
.add_attribute("signers_count", msg.signers.len().to_string()))
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> StdResult<Response> {
match msg {
ExecuteMsg::Propose {
title,
description,
msgs,
earliest,
} => execute_propose(deps, env, info, title, description, msgs, earliest),
ExecuteMsg::Vote { proposal_id, vote } => {
execute_vote(deps, env, info, proposal_id, vote)
}
ExecuteMsg::Execute { proposal_id } => {
execute_execute(deps, env, info, proposal_id)
}
ExecuteMsg::Close { proposal_id } => {
execute_close(deps, env, info, proposal_id)
}
}
}
pub fn execute_propose(
deps: DepsMut,
env: Env,
info: MessageInfo,
title: String,
description: String,
msgs: Vec<CosmosMsg<Empty>>,
earliest: Option<Expiration>,
) -> StdResult<Response> {
let voter = VOTERS.may_load(deps.storage, &info.sender)?;
if voter.is_none() {
return Err(StdError::generic_err("Unauthorized: not a voter"));
}
if msgs.is_empty() {
return Err(StdError::generic_err("Proposal must have at least one message"));
}
if msgs.len() > MAX_PROPOSAL_SIZE as usize {
return Err(StdError::generic_err(
format!("Proposal cannot have more than {} messages", MAX_PROPOSAL_SIZE),
));
}
if description.len() > MAX_DESC_LENGTH as usize {
return Err(StdError::generic_err(
format!("Description too long, max {} chars", MAX_DESC_LENGTH),
));
}
let cfg = CONFIG.load(deps.storage)?;
let mut prop_count = PROPOSAL_COUNT.load(deps.storage)?;
let expires = match &cfg.max_voting_period {
Duration::Time(duration) => Expiration::AtTime(env.block.time.plus_seconds(*duration)),
Duration::Height(duration) => Expiration::AtHeight(env.block.height + *duration),
};
prop_count += 1;
PROPOSAL_COUNT.save(deps.storage, &prop_count)?;
let voter = voter.unwrap();
if voter.weight.is_zero() {
return Err(StdError::generic_err("Voter has no weight"));
}
let proposal = Proposal {
id: prop_count,
title,
description,
msgs,
status: ProposalStatus::Open,
expires,
threshold: cfg.threshold.clone(),
total_weight: cfg.total_weight,
yes_weight: voter.weight,
no_weight: Uint128::zero(),
veto_weight: Uint128::zero(),
abstain_weight: Uint128::zero(),
proposer: info.sender.clone(),
created: env.block.time,
executed_at: None,
};
PROPOSALS.save(deps.storage, prop_count, &proposal)?;
let vote = Vote {
proposal_id: prop_count,
voter: info.sender.clone(),
vote: VoteType::Yes,
weight: voter.weight,
voted_at: env.block.time,
};
VOTES.save(deps.storage, (prop_count, &info.sender), &vote)?;
Ok(Response::new()
.add_attribute("method", "propose")
.add_attribute("proposal_id", prop_count.to_string())
.add_attribute("proposer", info.sender.to_string())
.add_attribute("yes_weight", proposal.yes_weight))
}
pub fn execute_vote(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
vote: VoteType,
) -> StdResult<Response> {
let voter = VOTERS.may_load(deps.storage, &info.sender)?;
if voter.is_none() {
return Err(StdError::generic_err("Unauthorized: not a voter"));
}
let voter = voter.unwrap();
let mut proposal = PROPOSALS.load(deps.storage, proposal_id)?;
if proposal.status != ProposalStatus::Open {
return Err(StdError::generic_err("Proposal is not open for voting"));
}
if proposal.expires.is_expired(&env.block) {
proposal.status = ProposalStatus::Expired;
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
return Err(StdError::generic_err("Proposal has expired"));
}
if VOTES.has(deps.storage, (proposal_id, &info.sender)) {
return Err(StdError::generic_err("Already voted on this proposal"));
}
let voter_weight = voter.weight;
let vote_record = Vote {
proposal_id,
voter: info.sender.clone(),
vote: vote.clone(),
weight: voter_weight,
voted_at: env.block.time,
};
VOTES.save(deps.storage, (proposal_id, &info.sender), &vote_record)?;
match vote {
VoteType::Yes => proposal.yes_weight += voter_weight,
VoteType::No => proposal.no_weight += voter_weight,
VoteType::Veto => proposal.veto_weight += voter_weight,
VoteType::Abstain => proposal.abstain_weight += voter_weight,
}
let threshold_response = proposal.threshold.is_quorum(
proposal.total_weight,
proposal.yes_weight,
proposal.no_weight,
proposal.veto_weight,
proposal.abstain_weight,
);
match threshold_response {
ThresholdResponse::Passed => {
proposal.status = ProposalStatus::Passed;
}
ThresholdResponse::Rejected { .. }
| ThresholdResponse::Vetoed { .. } => {
proposal.status = ProposalStatus::Rejected;
}
_ => {}
}
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
Ok(Response::new()
.add_attribute("method", "vote")
.add_attribute("proposal_id", proposal_id.to_string())
.add_attribute("voter", info.sender.to_string())
.add_attribute("vote", format!("{:?}", vote))
.add_attribute("status", format!("{:?}", proposal.status)))
}
pub fn execute_execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
) -> StdResult<Response> {
let mut proposal = PROPOSALS.load(deps.storage, proposal_id)?;
if proposal.status != ProposalStatus::Passed {
return Err(StdError::generic_err("Proposal is not in passed state"));
}
if proposal.expires.is_expired(&env.block) {
proposal.status = ProposalStatus::Expired;
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
return Err(StdError::generic_err("Proposal has expired"));
}
proposal.status = ProposalStatus::Executed;
proposal.executed_at = Some(env.block.time);
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
Ok(Response::new()
.add_messages(proposal.msgs)
.add_attribute("method", "execute")
.add_attribute("proposal_id", proposal_id.to_string())
.add_attribute("executor", info.sender.to_string()))
}
pub fn execute_close(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
) -> StdResult<Response> {
let mut proposal = PROPOSALS.load(deps.storage, proposal_id)?;
if proposal.status == ProposalStatus::Executed {
return Err(StdError::generic_err("Proposal already executed"));
}
if proposal.status == ProposalStatus::Closed {
return Err(StdError::generic_err("Proposal already closed"));
}
if !proposal.expires.is_expired(&env.block) {
return Err(StdError::generic_err("Proposal is not yet expired"));
}
proposal.status = ProposalStatus::Closed;
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
Ok(Response::new()
.add_attribute("method", "close")
.add_attribute("proposal_id", proposal_id.to_string()))
}
// ====== 查询接口 ======
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::Config {} => to_binary(&query_config(deps)?),
QueryMsg::Voter { address } => to_binary(&query_voter(deps, address)?),
QueryMsg::ListVoters { start_after, limit } => {
to_binary(&query_list_voters(deps, start_after, limit)?)
}
QueryMsg::Proposal { proposal_id } => {
to_binary(&query_proposal(deps, proposal_id)?)
}
QueryMsg::ListProposals { start_after, limit } => {
to_binary(&query_list_proposals(deps, start_after, limit)?)
}
QueryMsg::ReverseProposals { start_before, limit } => {
to_binary(&query_reverse_proposals(deps, start_before, limit)?)
}
QueryMsg::Vote { proposal_id, voter } => {
to_binary(&query_vote(deps, proposal_id, voter)?)
}
QueryMsg::ListVotes { proposal_id, start_after, limit } => {
to_binary(&query_list_votes(deps, proposal_id, start_after, limit)?)
}
QueryMsg::Threshold {} => to_binary(&query_threshold(deps)?),
QueryMsg::ProposalCount {} => to_binary(&query_proposal_count(deps)?),
}
}
pub fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
let cfg = CONFIG.load(deps.storage)?;
Ok(ConfigResponse {
threshold: cfg.threshold,
max_voting_period: cfg.max_voting_period,
total_weight: cfg.total_weight,
})
}
pub fn query_voter(deps: Deps, address: String) -> StdResult<VoterResponse> {
let addr = deps.api.addr_validate(&address)?;
let voter = VOTERS.may_load(deps.storage, &addr)?;
Ok(VoterResponse {
weight: voter.as_ref().map(|v| v.weight).unwrap_or_default(),
})
}
pub fn query_list_voters(
deps: Deps,
start_after: Option<String>,
limit: Option<u32>,
) -> StdResult<VotersResponse> {
let limit = limit.unwrap_or(30).min(100) as usize;
let start = start_after.as_ref().map(|s| deps.api.addr_validate(s).unwrap());
let voters: Vec<VoterDetail> = VOTERS
.range(deps.storage, start.as_ref(), None, Order::Ascending)
.take(limit)
.map(|item| {
let (addr, voter) = item?;
Ok(VoterDetail {
addr: addr.to_string(),
weight: voter.weight,
})
})
.collect::<StdResult<Vec<_>>>()?;
Ok(VotersResponse { voters })
}
pub fn query_proposal(deps: Deps, proposal_id: u64) -> StdResult<ProposalResponse> {
let proposal = PROPOSALS.load(deps.storage, proposal_id)?;
Ok(ProposalResponse {
id: proposal.id,
title: proposal.title,
description: proposal.description,
msgs: proposal.msgs,
status: proposal.status,
expires: proposal.expires,
yes_weight: proposal.yes_weight,
no_weight: proposal.no_weight,
veto_weight: proposal.veto_weight,
abstain_weight: proposal.abstain_weight,
total_weight: proposal.total_weight,
proposer: proposal.proposer.to_string(),
created: proposal.created,
})
}
pub fn query_list_proposals(
deps: Deps,
start_after: Option<u64>,
limit: Option<u32>,
) -> StdResult<ProposalsResponse> {
let limit = limit.unwrap_or(30).min(100) as usize;
let start = start_after.unwrap_or(0);
let proposals: Vec<ProposalResponse> = PROPOSALS
.range(deps.storage, Some(start + 1), None, Order::Ascending)
.take(limit)
.map(|item| {
let (id, proposal) = item?;
Ok(ProposalResponse {
id,
title: proposal.title,
description: proposal.description,
msgs: proposal.msgs,
status: proposal.status,
expires: proposal.expires,
yes_weight: proposal.yes_weight,
no_weight: proposal.no_weight,
veto_weight: proposal.veto_weight,
abstain_weight: proposal.abstain_weight,
total_weight: proposal.total_weight,
proposer: proposal.proposer.to_string(),
created: proposal.created,
})
})
.collect::<StdResult<Vec<_>>>()?;
Ok(ProposalsResponse { proposals })
}
pub fn query_reverse_proposals(
deps: Deps,
start_before: Option<u64>,
limit: Option<u32>,
) -> StdResult<ProposalsResponse> {
let limit = limit.unwrap_or(30).min(100) as usize;
let end = start_before.unwrap_or(u64::MAX);
let proposals: Vec<ProposalResponse> = PROPOSALS
.range(deps.storage, None, Some(end), Order::Descending)
.take(limit)
.map(|item| {
let (id, proposal) = item?;
Ok(ProposalResponse {
id,
title: proposal.title,
description: proposal.description,
msgs: proposal.msgs,
status: proposal.status,
expires: proposal.expires,
yes_weight: proposal.yes_weight,
no_weight: proposal.no_weight,
veto_weight: proposal.veto_weight,
abstain_weight: proposal.abstain_weight,
total_weight: proposal.total_weight,
proposer: proposal.proposer.to_string(),
created: proposal.created,
})
})
.collect::<StdResult<Vec<_>>>()?;
Ok(ProposalsResponse { proposals })
}
pub fn query_vote(deps: Deps, proposal_id: u64, voter: String) -> StdResult<VoteResponse> {
let addr = deps.api.addr_validate(&voter)?;
let vote = VOTES.may_load(deps.storage, (proposal_id, &addr))?;
match vote {
Some(v) => Ok(VoteResponse {
vote: Some(v.vote),
weight: v.weight,
}),
None => Ok(VoteResponse {
vote: None,
weight: Uint128::zero(),
}),
}
}
pub fn query_list_votes(
deps: Deps,
proposal_id: u64,
start_after: Option<String>,
limit: Option<u32>,
) -> StdResult<VotesResponse> {
let limit = limit.unwrap_or(30).min(100) as usize;
let start = start_after.as_ref().map(|s| deps.api.addr_validate(s).unwrap());
let votes: Vec<VoteInfo> = VOTES
.range(deps.storage, None, None, Order::Ascending)
.filter(|item| {
if let Ok(((pid, _), _)) = item {
*pid == proposal_id
} else {
false
}
})
.take(limit)
.map(|item| {
let ((_, voter_addr), vote) = item?;
Ok(VoteInfo {
voter: voter_addr.to_string(),
vote: vote.vote,
weight: vote.weight,
})
})
.collect::<StdResult<Vec<_>>>()?;
Ok(VotesResponse { votes })
}
pub fn query_threshold(deps: Deps) -> StdResult<ThresholdResponse> {
let cfg = CONFIG.load(deps.storage)?;
Ok(ThresholdResponse {
threshold: cfg.threshold,
total_weight: cfg.total_weight,
})
}
pub fn query_proposal_count(deps: Deps) -> StdResult<ProposalCountResponse> {
let count = PROPOSALS
.range(deps.storage, None, None, Order::Ascending)
.count() as u64;
Ok(ProposalCountResponse { count })
}
2.3 单元测试
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
use cosmwasm_std::{coins, from_binary, BankMsg, CosmosMsg, Uint128};
use cw_utils::{Duration, Threshold};
fn create_test_voters() -> Vec<Voter> {
vec![
Voter {
addr: "msg1creator".to_string(),
weight: Uint128::new(1),
},
Voter {
addr: "msg1voter1".to_string(),
weight: Uint128::new(1),
},
Voter {
addr: "msg1voter2".to_string(),
weight: Uint128::new(1),
},
Voter {
addr: "msg1voter3".to_string(),
weight: Uint128::new(1),
},
Voter {
addr: "msg1voter4".to_string(),
weight: Uint128::new(1),
},
]
}
fn setup_contract(deps: DepsMut) {
let instantiate_msg = InstantiateMsg {
signers: create_test_voters(),
threshold: Threshold::AbsoluteCount {
weight: Uint128::new(3),
},
max_voting_period: Duration::Time(604800),
};
let info = mock_info("msg1creator", &[]);
let env = mock_env();
instantiate(deps, env, info, instantiate_msg).unwrap();
}
#[test]
fn test_instantiate() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let config = CONFIG.load(&deps.storage).unwrap();
assert_eq!(config.total_weight, Uint128::new(5));
}
#[test]
fn test_propose() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1creator", &[]);
let propose_msg = ExecuteMsg::Propose {
title: "Transfer 100 MSG".to_string(),
description: "Payment for Q1 development".to_string(),
msgs: vec![CosmosMsg::Bank(BankMsg::Send {
to_address: "msg1recipient".to_string(),
amount: coins(100000000, "umsg"),
})],
earliest: None,
};
let res = execute(deps.as_mut(), env, info, propose_msg).unwrap();
assert_eq!(res.attributes[1].value, "1");
let proposal = PROPOSALS.load(&deps.storage, 1).unwrap();
assert_eq!(proposal.status, ProposalStatus::Open);
assert_eq!(proposal.yes_weight, Uint128::new(1));
assert_eq!(proposal.title, "Transfer 100 MSG");
}
#[test]
fn test_vote_and_execute() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1creator", &[]);
let propose_msg = ExecuteMsg::Propose {
title: "Test Transfer".to_string(),
description: "Test".to_string(),
msgs: vec![CosmosMsg::Bank(BankMsg::Send {
to_address: "msg1recipient".to_string(),
amount: coins(1000, "umsg"),
})],
earliest: None,
};
execute(deps.as_mut(), env, info, propose_msg).unwrap();
assert_eq!(PROPOSALS.load(&deps.storage, 1).unwrap().yes_weight, Uint128::new(1));
let env = mock_env();
let info = mock_info("msg1voter1", &[]);
let vote_msg = ExecuteMsg::Vote {
proposal_id: 1,
vote: VoteType::Yes,
};
execute(deps.as_mut(), env, info, vote_msg).unwrap();
let env = mock_env();
let info = mock_info("msg1voter2", &[]);
let vote_msg = ExecuteMsg::Vote {
proposal_id: 1,
vote: VoteType::Yes,
};
execute(deps.as_mut(), env, info, vote_msg).unwrap();
let proposal = PROPOSALS.load(&deps.storage, 1).unwrap();
assert_eq!(proposal.status, ProposalStatus::Passed);
assert_eq!(proposal.yes_weight, Uint128::new(3));
let env = mock_env();
let info = mock_info("msg1executor", &[]);
let exec_msg = ExecuteMsg::Execute { proposal_id: 1 };
let res = execute(deps.as_mut(), env, info, exec_msg).unwrap();
assert_eq!(res.messages.len(), 1);
let proposal = PROPOSALS.load(&deps.storage, 1).unwrap();
assert_eq!(proposal.status, ProposalStatus::Executed);
}
#[test]
fn test_veto_rejection() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1creator", &[]);
let propose_msg = ExecuteMsg::Propose {
title: "Bad Proposal".to_string(),
description: "Should be vetoed".to_string(),
msgs: vec![CosmosMsg::Bank(BankMsg::Send {
to_address: "msg1bad".to_string(),
amount: coins(999999, "umsg"),
})],
earliest: None,
};
execute(deps.as_mut(), env, info, propose_msg).unwrap();
let voters_nay = vec!["msg1voter1", "msg1voter2", "msg1voter3"];
for v in voters_nay {
let env = mock_env();
let info = mock_info(v, &[]);
let vote_msg = ExecuteMsg::Vote {
proposal_id: 1,
vote: VoteType::No,
};
execute(deps.as_mut(), env, info, vote_msg).unwrap();
}
let proposal = PROPOSALS.load(&deps.storage, 1).unwrap();
assert_eq!(proposal.status, ProposalStatus::Rejected);
}
#[test]
fn test_close_expired_proposal() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1creator", &[]);
let propose_msg = ExecuteMsg::Propose {
title: "Old Proposal".to_string(),
description: "Should expire".to_string(),
msgs: vec![CosmosMsg::Bank(BankMsg::Send {
to_address: "msg1recipient".to_string(),
amount: coins(100, "umsg"),
})],
earliest: None,
};
execute(deps.as_mut(), env, info, propose_msg).unwrap();
let mut env = mock_env();
env.block.time = env.block.time.plus_seconds(700000);
let info = mock_info("msg1voter1", &[]);
let vote_msg = ExecuteMsg::Vote {
proposal_id: 1,
vote: VoteType::Yes,
};
let err = execute(deps.as_mut(), env.clone(), info, vote_msg).unwrap_err();
assert_eq!(err, StdError::generic_err("Proposal has expired"));
let info = mock_info("msg1closer", &[]);
let close_msg = ExecuteMsg::Close { proposal_id: 1 };
execute(deps.as_mut(), env, info, close_msg).unwrap();
let proposal = PROPOSALS.load(&deps.storage, 1).unwrap();
assert_eq!(proposal.status, ProposalStatus::Closed);
}
#[test]
fn test_unauthorized_voter() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1stranger", &[]);
let propose_msg = ExecuteMsg::Propose {
title: "Hack".to_string(),
description: "Steal funds".to_string(),
msgs: vec![],
earliest: None,
};
let err = execute(deps.as_mut(), env, info, propose_msg).unwrap_err();
assert_eq!(err, StdError::generic_err("Unauthorized: not a voter"));
}
#[test]
fn test_double_vote_rejected() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1creator", &[]);
let propose_msg = ExecuteMsg::Propose {
title: "Test".to_string(),
description: "Test double vote".to_string(),
msgs: vec![CosmosMsg::Bank(BankMsg::Send {
to_address: "msg1recipient".to_string(),
amount: coins(100, "umsg"),
})],
earliest: None,
};
execute(deps.as_mut(), env, info, propose_msg).unwrap();
let env = mock_env();
let info = mock_info("msg1voter1", &[]);
let vote_msg = ExecuteMsg::Vote {
proposal_id: 1,
vote: VoteType::Yes,
};
execute(deps.as_mut(), env.clone(), info.clone(), vote_msg).unwrap();
let vote_msg2 = ExecuteMsg::Vote {
proposal_id: 1,
vote: VoteType::No,
};
let err = execute(deps.as_mut(), env, info, vote_msg2).unwrap_err();
assert_eq!(err, StdError::generic_err("Already voted on this proposal"));
}
#[test]
fn test_weighted_voting() {
let mut deps = mock_dependencies();
let voters = vec![
Voter {
addr: "msg1large".to_string(),
weight: Uint128::new(5),
},
Voter {
addr: "msg1small1".to_string(),
weight: Uint128::new(1),
},
Voter {
addr: "msg1small2".to_string(),
weight: Uint128::new(1),
},
];
let instantiate_msg = InstantiateMsg {
signers: voters,
threshold: Threshold::AbsoluteCount {
weight: Uint128::new(5),
},
max_voting_period: Duration::Time(86400),
};
let info = mock_info("msg1large", &[]);
let env = mock_env();
let deps_mut = deps.as_mut();
instantiate(deps_mut, env, info, instantiate_msg).unwrap();
let env = mock_env();
let info = mock_info("msg1large", &[]);
let propose_msg = ExecuteMsg::Propose {
title: "Large voter passes".to_string(),
description: "Weight 5 passes threshold 5".to_string(),
msgs: vec![CosmosMsg::Bank(BankMsg::Send {
to_address: "msg1dest".to_string(),
amount: coins(500, "umsg"),
})],
earliest: None,
};
let res = execute(deps.as_mut(), env, info, propose_msg).unwrap();
let proposal = PROPOSALS.load(&deps.storage, 1).unwrap();
assert_eq!(proposal.status, ProposalStatus::Passed);
}
}
2.4 消息与查询定义
use cosmwasm_std::{CosmosMsg, Empty};
use cw_utils::{Duration, Expiration, Threshold};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub signers: Vec<Voter>,
pub threshold: Threshold,
pub max_voting_period: Duration,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
Propose {
title: String,
description: String,
msgs: Vec<CosmosMsg<Empty>>,
earliest: Option<Expiration>,
},
Vote {
proposal_id: u64,
vote: VoteType,
},
Execute {
proposal_id: u64,
},
Close {
proposal_id: u64,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
Config {},
Voter { address: String },
ListVoters {
start_after: Option<String>,
limit: Option<u32>,
},
Proposal {
proposal_id: u64,
},
ListProposals {
start_after: Option<u64>,
limit: Option<u32>,
},
ReverseProposals {
start_before: Option<u64>,
limit: Option<u32>,
},
Vote {
proposal_id: u64,
voter: String,
},
ListVotes {
proposal_id: u64,
start_after: Option<String>,
limit: Option<u32>,
},
Threshold {},
ProposalCount {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ConfigResponse {
pub threshold: Threshold,
pub max_voting_period: Duration,
pub total_weight: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VoterResponse {
pub weight: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VoterDetail {
pub addr: String,
pub weight: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VotersResponse {
pub voters: Vec<VoterDetail>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ProposalResponse {
pub id: u64,
pub title: String,
pub description: String,
pub msgs: Vec<CosmosMsg<Empty>>,
pub status: ProposalStatus,
pub expires: Expiration,
pub yes_weight: Uint128,
pub no_weight: Uint128,
pub veto_weight: Uint128,
pub abstain_weight: Uint128,
pub total_weight: Uint128,
pub proposer: String,
pub created: Timestamp,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ProposalsResponse {
pub proposals: Vec<ProposalResponse>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VoteResponse {
pub vote: Option<VoteType>,
pub weight: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VoteInfo {
pub voter: String,
pub vote: VoteType,
pub weight: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VotesResponse {
pub votes: Vec<VoteInfo>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ThresholdResponse {
pub threshold: Threshold,
pub total_weight: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ProposalCountResponse {
pub count: u64,
}
3. 合约完整实现 — cw3_flex_multisig
3.1 Flex Multisig 设计
cw3_flex_multisig 在 cw3_fixed_multisig 的基础上增加了以下能力:
- 动态成员管理:通过提案添加或移除签名者
- 阈值调整:通过提案修改阈值
- 权重调整:通过提案调整成员的投票权重
3.2 额外存储结构
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FlexExecuteMsg {
Propose {
title: String,
description: String,
msgs: Vec<CosmosMsg<Empty>>,
earliest: Option<Expiration>,
},
Vote {
proposal_id: u64,
vote: VoteType,
},
Execute {
proposal_id: u64,
},
Close {
proposal_id: u64,
},
UpdateConfig {
threshold: Option<Threshold>,
max_voting_period: Option<Duration>,
},
AddVoter {
addr: String,
weight: Option<Uint128>,
},
RemoveVoter {
addr: String,
},
UpdateVoterWeight {
addr: String,
weight: Uint128,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MemberChangeProposal {
pub additions: Vec<Voter>,
pub removals: Vec<String>,
pub weight_changes: Vec<(String, Uint128)>,
pub threshold_change: Option<Threshold>,
}
pub const OWNER: Item<Addr> = Item::new("owner");
pub const MEMBER_CHANGE_PROPOSALS: Map<u64, MemberChangeProposal> =
Map::new("member_changes");
3.3 Flex Multisig 完整实现
use cosmwasm_std::{
entry_point, to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut,
Empty, Env, MessageInfo, Order, Response, StdError, StdResult, Uint128,
};
use cw2::set_contract_version;
use cw_utils::{Duration, Expiration, Threshold, ThresholdResponse};
const CONTRACT_NAME: &str = "crates.io:cw3-flex-multisig";
const CONTRACT_VERSION: &str = "1.0.0";
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
deps: DepsMut,
_env: Env,
info: MessageInfo,
msg: InstantiateMsg,
) -> StdResult<Response> {
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
let total_weight = msg
.signers
.iter()
.fold(Uint128::zero(), |acc, v| acc + v.weight);
let cfg = Config {
threshold: msg.threshold,
max_voting_period: msg.max_voting_period,
total_weight,
};
CONFIG.save(deps.storage, &cfg)?;
for voter in msg.signers.iter() {
let voter_addr = deps.api.addr_validate(&voter.addr)?;
VOTERS.save(
deps.storage,
&voter_addr,
&Voter {
addr: voter.addr.clone(),
weight: voter.weight,
},
)?;
}
PROPOSAL_COUNT.save(deps.storage, &0u64)?;
OWNER.save(deps.storage, &info.sender)?;
Ok(Response::default()
.add_attribute("method", "instantiate")
.add_attribute("owner", info.sender.to_string())
.add_attribute("total_weight", total_weight)
.add_attribute("voters", msg.signers.len().to_string()))
}
fn is_voter(deps: &DepsMut, addr: &Addr) -> bool {
VOTERS.has(deps.storage, addr)
}
fn add_voter(deps: DepsMut, addr: &Addr, weight: Uint128) -> StdResult<()> {
if weight.is_zero() {
return Err(StdError::generic_err("Voter weight cannot be zero"));
}
if is_voter(&deps, addr) {
return Err(StdError::generic_err("Voter already exists"));
}
VOTERS.save(
deps.storage,
addr,
&Voter {
addr: addr.to_string(),
weight,
},
)?;
let mut cfg = CONFIG.load(deps.storage)?;
cfg.total_weight += weight;
CONFIG.save(deps.storage, &cfg)?;
Ok(())
}
fn remove_voter(deps: DepsMut, addr: &Addr) -> StdResult<()> {
let voter = VOTERS.may_load(deps.storage, addr)?;
match voter {
Some(v) => {
VOTERS.remove(deps.storage, addr);
let mut cfg = CONFIG.load(deps.storage)?;
cfg.total_weight -= v.weight;
CONFIG.save(deps.storage, &cfg)?;
Ok(())
}
None => Err(StdError::generic_err("Voter not found")),
}
}
fn update_voter_weight(deps: DepsMut, addr: &Addr, new_weight: Uint128) -> StdResult<()> {
if new_weight.is_zero() {
return Err(StdError::generic_err("Voter weight cannot be zero"));
}
let voter = VOTERS.may_load(deps.storage, addr)?;
match voter {
Some(mut v) => {
let old_weight = v.weight;
v.weight = new_weight;
VOTERS.save(deps.storage, addr, &v)?;
let mut cfg = CONFIG.load(deps.storage)?;
cfg.total_weight = cfg.total_weight - old_weight + new_weight;
CONFIG.save(deps.storage, &cfg)?;
Ok(())
}
None => Err(StdError::generic_err("Voter not found")),
}
}
fn update_threshold(deps: DepsMut, threshold: Threshold) -> StdResult<()> {
let mut cfg = CONFIG.load(deps.storage)?;
cfg.threshold = threshold;
CONFIG.save(deps.storage, &cfg)?;
Ok(())
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> StdResult<Response> {
match msg {
ExecuteMsg::Propose { title, description, msgs, earliest } => {
execute_propose(deps, env, info, title, description, msgs, earliest)
}
ExecuteMsg::Vote { proposal_id, vote } => {
execute_vote(deps, env, info, proposal_id, vote)
}
ExecuteMsg::Execute { proposal_id } => {
execute_execute(deps, env, info, proposal_id)
}
ExecuteMsg::Close { proposal_id } => {
execute_close(deps, env, info, proposal_id)
}
ExecuteMsg::UpdateConfig {
threshold,
max_voting_period,
} => execute_update_config(deps, env, info, threshold, max_voting_period),
ExecuteMsg::AddVoter { addr, weight } => {
execute_add_voter(deps, env, info, addr, weight)
}
ExecuteMsg::RemoveVoter { addr } => {
execute_remove_voter(deps, env, info, addr)
}
ExecuteMsg::UpdateVoterWeight { addr, weight } => {
execute_update_voter_weight(deps, env, info, addr, weight)
}
}
}
pub fn execute_update_config(
deps: DepsMut,
_env: Env,
info: MessageInfo,
threshold: Option<Threshold>,
max_voting_period: Option<Duration>,
) -> StdResult<Response> {
let owner = OWNER.load(deps.storage)?;
if info.sender != owner {
return Err(StdError::generic_err("Unauthorized: only owner can update config"));
}
let mut cfg = CONFIG.load(deps.storage)?;
if let Some(t) = threshold {
cfg.threshold = t;
}
if let Some(d) = max_voting_period {
cfg.max_voting_period = d;
}
CONFIG.save(deps.storage, &cfg)?;
Ok(Response::new()
.add_attribute("method", "update_config")
.add_attribute("sender", info.sender.to_string()))
}
pub fn execute_add_voter(
deps: DepsMut,
_env: Env,
info: MessageInfo,
addr: String,
weight: Option<Uint128>,
) -> StdResult<Response> {
let owner = OWNER.load(deps.storage)?;
if info.sender != owner {
return Err(StdError::generic_err("Unauthorized: only owner can add voters"));
}
let voter_addr = deps.api.addr_validate(&addr)?;
let voter_weight = weight.unwrap_or(Uint128::new(1));
add_voter(deps, &voter_addr, voter_weight)?;
Ok(Response::new()
.add_attribute("method", "add_voter")
.add_attribute("addr", addr)
.add_attribute("weight", voter_weight.to_string()))
}
pub fn execute_remove_voter(
deps: DepsMut,
_env: Env,
info: MessageInfo,
addr: String,
) -> StdResult<Response> {
let owner = OWNER.load(deps.storage)?;
if info.sender != owner {
return Err(StdError::generic_err("Unauthorized: only owner can remove voters"));
}
let voter_addr = deps.api.addr_validate(&addr)?;
remove_voter(deps, &voter_addr)?;
Ok(Response::new()
.add_attribute("method", "remove_voter")
.add_attribute("addr", addr))
}
pub fn execute_update_voter_weight(
deps: DepsMut,
_env: Env,
info: MessageInfo,
addr: String,
weight: Uint128,
) -> StdResult<Response> {
let owner = OWNER.load(deps.storage)?;
if info.sender != owner {
return Err(StdError::generic_err("Unauthorized: only owner can change voter weights"));
}
let voter_addr = deps.api.addr_validate(&addr)?;
update_voter_weight(deps, &voter_addr, weight)?;
Ok(Response::new()
.add_attribute("method", "update_voter_weight")
.add_attribute("addr", addr)
.add_attribute("new_weight", weight.to_string()))
}
pub fn execute_propose(
deps: DepsMut,
env: Env,
info: MessageInfo,
title: String,
description: String,
msgs: Vec<CosmosMsg<Empty>>,
earliest: Option<Expiration>,
) -> StdResult<Response> {
let voter = VOTERS.may_load(deps.storage, &info.sender)?;
if voter.is_none() {
return Err(StdError::generic_err("Unauthorized: not a voter"));
}
let voter = voter.unwrap();
if msgs.is_empty() {
return Err(StdError::generic_err("Proposal must have at least one message"));
}
let cfg = CONFIG.load(deps.storage)?;
let mut prop_count = PROPOSAL_COUNT.load(deps.storage)?;
let expires = match &cfg.max_voting_period {
Duration::Time(duration) => Expiration::AtTime(env.block.time.plus_seconds(*duration)),
Duration::Height(duration) => Expiration::AtHeight(env.block.height + *duration),
};
prop_count += 1;
PROPOSAL_COUNT.save(deps.storage, &prop_count)?;
if voter.weight.is_zero() {
return Err(StdError::generic_err("Voter has no weight"));
}
let proposal = Proposal {
id: prop_count,
title,
description,
msgs,
status: ProposalStatus::Open,
expires,
threshold: cfg.threshold.clone(),
total_weight: cfg.total_weight,
yes_weight: voter.weight,
no_weight: Uint128::zero(),
veto_weight: Uint128::zero(),
abstain_weight: Uint128::zero(),
proposer: info.sender.clone(),
created: env.block.time,
};
PROPOSALS.save(deps.storage, prop_count, &proposal)?;
let vote = Vote {
proposal_id: prop_count,
voter: info.sender.clone(),
vote: VoteType::Yes,
weight: voter.weight,
voted_at: env.block.time,
};
VOTES.save(deps.storage, (prop_count, &info.sender), &vote)?;
Ok(Response::new()
.add_attribute("method", "propose")
.add_attribute("proposal_id", prop_count.to_string())
.add_attribute("proposer", info.sender.to_string()))
}
pub fn execute_vote(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
vote: VoteType,
) -> StdResult<Response> {
let voter = VOTERS.may_load(deps.storage, &info.sender)?;
if voter.is_none() {
return Err(StdError::generic_err("Unauthorized: not a voter"));
}
let voter = voter.unwrap();
let mut proposal = PROPOSALS.load(deps.storage, proposal_id)?;
if proposal.status != ProposalStatus::Open {
return Err(StdError::generic_err("Proposal is not open for voting"));
}
if proposal.expires.is_expired(&env.block) {
proposal.status = ProposalStatus::Expired;
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
return Err(StdError::generic_err("Proposal has expired"));
}
if VOTES.has(deps.storage, (proposal_id, &info.sender)) {
return Err(StdError::generic_err("Already voted on this proposal"));
}
let voter_weight = voter.weight;
let vote_record = Vote {
proposal_id,
voter: info.sender.clone(),
vote: vote.clone(),
weight: voter_weight,
voted_at: env.block.time,
};
VOTES.save(deps.storage, (proposal_id, &info.sender), &vote_record)?;
match vote {
VoteType::Yes => proposal.yes_weight += voter_weight,
VoteType::No => proposal.no_weight += voter_weight,
VoteType::Veto => proposal.veto_weight += voter_weight,
VoteType::Abstain => proposal.abstain_weight += voter_weight,
}
let current_cfg = CONFIG.load(deps.storage)?;
let threshold_response = current_cfg.threshold.is_quorum(
proposal.total_weight,
proposal.yes_weight,
proposal.no_weight,
proposal.veto_weight,
proposal.abstain_weight,
);
match threshold_response {
ThresholdResponse::Passed => {
proposal.status = ProposalStatus::Passed;
}
ThresholdResponse::Rejected { .. } | ThresholdResponse::Vetoed { .. } => {
proposal.status = ProposalStatus::Rejected;
}
_ => {}
}
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
Ok(Response::new()
.add_attribute("method", "vote")
.add_attribute("proposal_id", proposal_id.to_string())
.add_attribute("voter", info.sender.to_string())
.add_attribute("vote", format!("{:?}", vote)))
}
pub fn execute_execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
) -> StdResult<Response> {
let mut proposal = PROPOSALS.load(deps.storage, proposal_id)?;
if proposal.status != ProposalStatus::Passed {
return Err(StdError::generic_err("Proposal is not in passed state"));
}
if proposal.expires.is_expired(&env.block) {
proposal.status = ProposalStatus::Expired;
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
return Err(StdError::generic_err("Proposal has expired"));
}
proposal.status = ProposalStatus::Executed;
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
Ok(Response::new()
.add_messages(proposal.msgs)
.add_attribute("method", "execute")
.add_attribute("proposal_id", proposal_id.to_string())
.add_attribute("executor", info.sender.to_string()))
}
pub fn execute_close(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
) -> StdResult<Response> {
let mut proposal = PROPOSALS.load(deps.storage, proposal_id)?;
if proposal.status == ProposalStatus::Executed {
return Err(StdError::generic_err("Proposal already executed"));
}
if proposal.status == ProposalStatus::Closed {
return Err(StdError::generic_err("Proposal already closed"));
}
if !proposal.expires.is_expired(&env.block) {
return Err(StdError::generic_err("Proposal is not yet expired"));
}
proposal.status = ProposalStatus::Closed;
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
Ok(Response::new()
.add_attribute("method", "close")
.add_attribute("proposal_id", proposal_id.to_string()))
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::Config {} => to_binary(&query_config(deps)?),
QueryMsg::Voter { address } => to_binary(&query_voter(deps, address)?),
QueryMsg::ListVoters { start_after, limit } => {
to_binary(&query_list_voters(deps, start_after, limit)?)
}
QueryMsg::Proposal { proposal_id } => {
to_binary(&query_proposal(deps, proposal_id)?)
}
QueryMsg::ListProposals { start_after, limit } => {
to_binary(&query_list_proposals(deps, start_after, limit)?)
}
QueryMsg::ReverseProposals { start_before, limit } => {
to_binary(&query_reverse_proposals(deps, start_before, limit)?)
}
QueryMsg::Vote { proposal_id, voter } => {
to_binary(&query_vote(deps, proposal_id, voter)?)
}
QueryMsg::ListVotes { proposal_id, start_after, limit } => {
to_binary(&query_list_votes(deps, proposal_id, start_after, limit)?)
}
QueryMsg::Threshold {} => to_binary(&query_threshold(deps)?),
QueryMsg::ProposalCount {} => to_binary(&query_proposal_count(deps)?),
QueryMsg::Owner {} => to_binary(&query_owner(deps)?),
QueryMsg::MemberHistory { start_after, limit } => {
to_binary(&query_member_history(deps, start_after, limit)?)
}
}
}
fn query_owner(deps: Deps) -> StdResult<OwnerResponse> {
let owner = OWNER.load(deps.storage)?;
Ok(OwnerResponse {
owner: owner.to_string(),
})
}
fn query_member_history(
deps: Deps,
start_after: Option<u64>,
limit: Option<u32>,
) -> StdResult<MemberHistoryResponse> {
let limit = limit.unwrap_or(20).min(50) as usize;
let start = start_after.unwrap_or(0);
let changes: Vec<MemberChangeRecord> = MEMBER_CHANGE_PROPOSALS
.range(deps.storage, Some(start + 1), None, Order::Ascending)
.take(limit)
.map(|item| {
let (id, change) = item?;
Ok(MemberChangeRecord {
proposal_id: id,
additions: change.additions,
removals: change.removals,
weight_changes: change.weight_changes,
threshold_change: change.threshold_change,
})
})
.collect::<StdResult<Vec<_>>>()?;
Ok(MemberHistoryResponse { changes })
}
3.4 Flex 查询响应
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct OwnerResponse {
pub owner: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MemberChangeRecord {
pub proposal_id: u64,
pub additions: Vec<Voter>,
pub removals: Vec<String>,
pub weight_changes: Vec<(String, Uint128)>,
pub threshold_change: Option<Threshold>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MemberHistoryResponse {
pub changes: Vec<MemberChangeRecord>,
}
3.5 Flex Multisig 测试
#[cfg(test)]
mod flex_tests {
use super::*;
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
use cosmwasm_std::{coins, BankMsg, CosmosMsg};
fn setup_flex_contract(deps: DepsMut) {
let voters = vec![
Voter { addr: "msg1owner".to_string(), weight: Uint128::new(2) },
Voter { addr: "msg1alice".to_string(), weight: Uint128::new(1) },
Voter { addr: "msg1bob".to_string(), weight: Uint128::new(1) },
];
let msg = InstantiateMsg {
signers: voters,
threshold: Threshold::AbsoluteCount { weight: Uint128::new(2) },
max_voting_period: Duration::Time(86400),
};
let info = mock_info("msg1owner", &[]);
let env = mock_env();
instantiate(deps, env, info, msg).unwrap();
}
#[test]
fn test_add_voter() {
let mut deps = mock_dependencies();
setup_flex_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1owner", &[]);
let msg = ExecuteMsg::AddVoter {
addr: "msg1charlie".to_string(),
weight: Some(Uint128::new(1)),
};
execute(deps.as_mut(), env, info, msg).unwrap();
let voter = query_voter(deps.as_ref(), "msg1charlie".to_string()).unwrap();
assert_eq!(voter.weight, Uint128::new(1));
}
#[test]
fn test_remove_voter() {
let mut deps = mock_dependencies();
setup_flex_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1owner", &[]);
let msg = ExecuteMsg::RemoveVoter {
addr: "msg1alice".to_string(),
};
execute(deps.as_mut(), env, info, msg).unwrap();
let voter = query_voter(deps.as_ref(), "msg1alice".to_string()).unwrap();
assert_eq!(voter.weight, Uint128::zero());
}
#[test]
fn test_unauthorized_add_voter() {
let mut deps = mock_dependencies();
setup_flex_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1alice", &[]);
let msg = ExecuteMsg::AddVoter {
addr: "msg1mallory".to_string(),
weight: None,
};
let err = execute(deps.as_mut(), env, info, msg).unwrap_err();
assert!(err.to_string().contains("Unauthorized"));
}
#[test]
fn test_update_threshold() {
let mut deps = mock_dependencies();
setup_flex_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1owner", &[]);
let msg = ExecuteMsg::UpdateConfig {
threshold: Some(Threshold::AbsoluteCount { weight: Uint128::new(3) }),
max_voting_period: None,
};
execute(deps.as_mut(), env, info, msg).unwrap();
let cfg = CONFIG.load(&deps.storage).unwrap();
match cfg.threshold {
Threshold::AbsoluteCount { weight } => assert_eq!(weight, Uint128::new(3)),
_ => panic!("wrong threshold type"),
}
}
#[test]
fn test_dynamic_voter_affects_voting() {
let mut deps = mock_dependencies();
setup_flex_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("msg1owner", &[]);
let msg = ExecuteMsg::AddVoter {
addr: "msg1heavy".to_string(),
weight: Some(Uint128::new(5)),
};
execute(deps.as_mut(), env, info, msg).unwrap();
let env = mock_env();
let info = mock_info("msg1heavy", &[]);
let propose_msg = ExecuteMsg::Propose {
title: "Heavy weight passes".to_string(),
description: "Weight 5 >= threshold 2".to_string(),
msgs: vec![CosmosMsg::Bank(BankMsg::Send {
to_address: "msg1dest".to_string(),
amount: coins(100, "umsg"),
})],
earliest: None,
};
execute(deps.as_mut(), env, info, propose_msg).unwrap();
let proposal = PROPOSALS.load(&deps.storage, 1).unwrap();
assert_eq!(proposal.status, ProposalStatus::Passed);
}
}
4. 多签操作流程
4.1 MSG Chain 环境准备
安装 msg-chain-devkit
curl -sSL https://msgchain.org/install.sh | bash
msg-chain-devkit --version
msg-chain-devkit config set chain-id msg-chain-1
msg-chain-devkit config set node https://rpc.msgchain.org
创建多签账户
msg-chain-devkit keys add signer1
msg-chain-devkit keys add signer2
msg-chain-devkit keys add signer3
msg-chain-devkit keys add signer4
msg-chain-devkit keys add signer5
msg-chain-devkit keys list
部署多签合约
msg-chain-devkit tx wasm store cw3_fixed_multisig.wasm \
--from signer1 --gas auto --gas-adjustment 1.3
msg-chain-devkit query wasm list-code
INIT='{
"signers": [
{"addr": "msg1signer1...", "weight": "1"},
{"addr": "msg1signer2...", "weight": "1"},
{"addr": "msg1signer3...", "weight": "1"},
{"addr": "msg1signer4...", "weight": "1"},
{"addr": "msg1signer5...", "weight": "1"}
],
"threshold": {"absolute_count": {"weight": 3}},
"max_voting_period": {"time": 604800}
}'
msg-chain-devkit tx wasm instantiate CODE_ID "$INIT" \
--from signer1 --label "team-multisig-3-5" --admin msg1admin...
4.2 Python SDK 操作
#!/usr/bin/env python3
"""
MSG Chain 多签钱包操作示例
"""
import json
import time
from typing import List, Optional
from dataclasses import dataclass
from msg_chain_sdk import (
MsgChainClient,
Wallet,
Coin,
)
CHAIN_ID = "msg-chain-1"
RPC_ENDPOINT = "https://rpc.msgchain.org"
MULTISIG_CONTRACT = "msg1multisigcontractaddress..."
GAS_PRICE = 1_000_000_000
GAS_LIMIT = 500_000
@dataclass
class Proposal:
id: int
title: str
description: str
status: str
yes_weight: int
no_weight: int
total_weight: int
expires: int
proposer: str
@dataclass
class VoterInfo:
address: str
weight: int
class MultisigClient:
"""多签钱包客户端"""
def __init__(self, rpc: str, chain_id: str, contract_addr: str, gas_price: int = 1_000_000_000):
self.client = MsgChainClient(rpc, chain_id)
self.contract_addr = contract_addr
self.gas_price = gas_price
def propose(self, wallet: Wallet, title: str, description: str,
msgs: List[dict], earliest: Optional[int] = None) -> int:
propose_msg = {"propose": {"title": title, "description": description, "msgs": msgs}}
if earliest is not None:
propose_msg["propose"]["earliest"] = {"at_time": str(earliest)}
result = self.client.execute_contract(
wallet=wallet, contract=self.contract_addr, msg=propose_msg,
gas_limit=GAS_LIMIT, gas_price=str(self.gas_price),
)
proposal_id = self._parse_proposal_id(result)
print(f"[✓] 提案 #{proposal_id} 已创建: {title}")
return proposal_id
def vote(self, wallet: Wallet, proposal_id: int, vote_type: str) -> bool:
vote_msg = {"vote": {"proposal_id": proposal_id, "vote": vote_type}}
result = self.client.execute_contract(
wallet=wallet, contract=self.contract_addr, msg=vote_msg,
gas_limit=200_000, gas_price=str(self.gas_price),
)
print(f"[✓] {wallet.address()} 对提案 #{proposal_id} 投了 {vote_type}")
return True
def execute(self, wallet: Wallet, proposal_id: int) -> bool:
execute_msg = {"execute": {"proposal_id": proposal_id}}
result = self.client.execute_contract(
wallet=wallet, contract=self.contract_addr, msg=execute_msg,
gas_limit=GAS_LIMIT, gas_price=str(self.gas_price),
)
print(f"[✓] 提案 #{proposal_id} 已执行")
return True
def close(self, wallet: Wallet, proposal_id: int) -> bool:
close_msg = {"close": {"proposal_id": proposal_id}}
result = self.client.execute_contract(
wallet=wallet, contract=self.contract_addr, msg=close_msg,
gas_limit=200_000, gas_price=str(self.gas_price),
)
print(f"[✓] 提案 #{proposal_id} 已关闭")
return True
def get_proposal(self, proposal_id: int) -> Optional[Proposal]:
query_msg = {"proposal": {"proposal_id": proposal_id}}
result = self.client.query_contract(self.contract_addr, query_msg)
if "error" in result:
print(f"[✗] 查询失败: {result['error']}")
return None
prop = result["data"]
return Proposal(
id=prop["id"], title=prop["title"], description=prop["description"],
status=prop["status"], yes_weight=int(prop["yes_weight"]),
no_weight=int(prop["no_weight"]), total_weight=int(prop["total_weight"]),
expires=prop["expires"], proposer=prop["proposer"],
)
def get_voters(self) -> List[VoterInfo]:
query_msg = {"list_voters": {}}
result = self.client.query_contract(self.contract_addr, query_msg)
voters = []
for v in result["data"]["voters"]:
voters.append(VoterInfo(address=v["addr"], weight=int(v["weight"])))
return voters
def get_config(self) -> dict:
query_msg = {"config": {}}
result = self.client.query_contract(self.contract_addr, query_msg)
return result["data"]
def _parse_proposal_id(self, result: dict) -> int:
for event in result.get("events", []):
for attr in event.get("attributes", []):
if attr.get("key") == "proposal_id":
return int(attr["value"])
raise ValueError("无法解析 proposal_id")
def demo_multisig_workflow():
client = MultisigClient(RPC_ENDPOINT, CHAIN_ID, MULTISIG_CONTRACT)
signer1 = Wallet.from_mnemonic("mnemonic phrase for signer 1...")
signer2 = Wallet.from_mnemonic("mnemonic phrase for signer 2...")
signer3 = Wallet.from_mnemonic("mnemonic phrase for signer 3...")
config = client.get_config()
print(f"合约配置: {config}")
voters = client.get_voters()
print(f"签名者 ({len(voters)}): {voters}")
print("\n--- 创建转账提案 ---")
transfer_msg = {
"bank": {
"send": {
"to_address": "msg1recipientaddress...",
"amount": [{"denom": "umsg", "amount": "1000000000000000000"}]
}
}
}
proposal_id = client.propose(
wallet=signer1, title="支付 Q1 开发者资助",
description="向 msg1recipient 转账 1 MSG",
msgs=[transfer_msg],
)
print("\n--- 投票 ---")
client.vote(signer2, proposal_id, "yes")
proposal = client.get_proposal(proposal_id)
print(f"当前状态: {proposal.status}, 赞成: {proposal.yes_weight}/{proposal.total_weight}")
client.vote(signer3, proposal_id, "yes")
proposal = client.get_proposal(proposal_id)
print(f"投票后状态: {proposal.status}")
if proposal.status == "passed":
print("\n--- 执行提案 ---")
client.execute(signer1, proposal_id)
proposal = client.get_proposal(proposal_id)
print(f"最终状态: {proposal.status}")
return proposal_id
def create_multisig_transfer(client: MultisigClient, proposer: Wallet,
recipients: List[tuple[str, int]],
title: str = "批量转账", description: str = "") -> int:
msgs = []
for addr, amount in recipients:
msgs.append({
"bank": {
"send": {
"to_address": addr,
"amount": [{"denom": "umsg", "amount": str(amount)}],
}
}
})
return client.propose(wallet=proposer, title=title,
description=description or f"向 {len(recipients)} 个地址批量转账",
msgs=msgs)
def create_contract_exec_multisig(client: MultisigClient, proposer: Wallet,
contract_addr: str, exec_msg: dict,
funds: Optional[List[dict]] = None,
title: str = "合约调用", description: str = "") -> int:
wasm_msg = {
"wasm": {
"execute": {
"contract_addr": contract_addr,
"msg": exec_msg,
"funds": funds or [],
}
}
}
return client.propose(wallet=proposer, title=title,
description=description or f"调用合约 {contract_addr}",
msgs=[wasm_msg])
def monitor_proposal_status(client: MultisigClient, proposal_id: int,
interval: int = 5, timeout: int = 300) -> str:
start = time.time()
while time.time() - start < timeout:
proposal = client.get_proposal(proposal_id)
if proposal is None:
break
print(f"[{time.time() - start:.0f}s] 提案 #{proposal_id} 状态: {proposal.status}")
if proposal.status in ("passed", "rejected", "executed", "closed", "expired"):
return proposal.status
time.sleep(interval)
return "timeout"
4.3 TypeScript SDK 操作
// msig-operations.ts
import {
MsgChainClient,
Wallet,
MsgExecuteContract,
calculateFee,
GasPrice,
} from "@msg-chain/sdk";
const CHAIN_ID = "msg-chain-1";
const RPC_ENDPOINT = "https://rpc.msgchain.org";
const CONTRACT_ADDR = "msg1multisigcontract...";
const GAS_PRICE = GasPrice.fromString("1000000000attoMSG");
interface Voter {
addr: string;
weight: string;
}
interface Proposal {
id: number;
title: string;
description: string;
status: string;
yes_weight: string;
no_weight: string;
veto_weight: string;
abstain_weight: string;
total_weight: string;
expires: any;
proposer: string;
}
interface Config {
threshold: any;
max_voting_period: any;
total_weight: string;
}
class MultisigClient {
private client: MsgChainClient;
private contractAddr: string;
constructor(rpc: string, chainId: string, contractAddr: string) {
this.client = new MsgChainClient({ rpc, chainId });
this.contractAddr = contractAddr;
}
async propose(
wallet: Wallet, title: string, description: string,
msgs: object[], earliest?: number
): Promise<number> {
const proposeMsg: MsgExecuteContract = {
typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
value: {
sender: wallet.address,
contract: this.contractAddr,
msg: Buffer.from(JSON.stringify({
propose: { title, description, msgs,
...(earliest ? { earliest: { at_time: earliest.toString() } } : {}) },
})).toString("base64"),
funds: [],
},
};
const fee = calculateFee(500_000, GAS_PRICE);
const result = await this.client.signAndBroadcast(wallet, [proposeMsg], fee);
const proposalId = this.parseProposalId(result);
console.log(`[✓] 提案 #${proposalId} 已创建: ${title}`);
return proposalId;
}
async vote(
wallet: Wallet, proposalId: number, voteType: "yes" | "no" | "veto" | "abstain"
): Promise<boolean> {
const voteMsg: MsgExecuteContract = {
typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
value: {
sender: wallet.address, contract: this.contractAddr,
msg: Buffer.from(JSON.stringify({
vote: { proposal_id: proposalId, vote: voteType },
})).toString("base64"),
funds: [],
},
};
const fee = calculateFee(200_000, GAS_PRICE);
await this.client.signAndBroadcast(wallet, [voteMsg], fee);
console.log(`[✓] ${wallet.address} 对提案 #${proposalId} 投了 ${voteType}`);
return true;
}
async execute(wallet: Wallet, proposalId: number): Promise<boolean> {
const executeMsg: MsgExecuteContract = {
typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
value: {
sender: wallet.address, contract: this.contractAddr,
msg: Buffer.from(JSON.stringify({
execute: { proposal_id: proposalId },
})).toString("base64"),
funds: [],
},
};
const fee = calculateFee(500_000, GAS_PRICE);
await this.client.signAndBroadcast(wallet, [executeMsg], fee);
console.log(`[✓] 提案 #${proposalId} 已执行`);
return true;
}
async close(wallet: Wallet, proposalId: number): Promise<boolean> {
const closeMsg: MsgExecuteContract = {
typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract",
value: {
sender: wallet.address, contract: this.contractAddr,
msg: Buffer.from(JSON.stringify({
close: { proposal_id: proposalId },
})).toString("base64"),
funds: [],
},
};
const fee = calculateFee(200_000, GAS_PRICE);
await this.client.signAndBroadcast(wallet, [closeMsg], fee);
console.log(`[✓] 提案 #${proposalId} 已关闭`);
return true;
}
async getProposal(proposalId: number): Promise<Proposal | null> {
const result: any = await this.client.queryContractSmart(
this.contractAddr, { proposal: { proposal_id: proposalId } }
);
return result as Proposal;
}
async getVoters(): Promise<Voter[]> {
const result: any = await this.client.queryContractSmart(
this.contractAddr, { list_voters: {} }
);
return result.voters;
}
async getConfig(): Promise<Config> {
const result: any = await this.client.queryContractSmart(
this.contractAddr, { config: {} }
);
return result;
}
async listProposals(): Promise<Proposal[]> {
const result: any = await this.client.queryContractSmart(
this.contractAddr, { list_proposals: {} }
);
return result.proposals || [];
}
private parseProposalId(result: any): number {
for (const event of result.events || []) {
for (const attr of event.attributes || []) {
if (attr.key === "proposal_id") return parseInt(attr.value, 10);
}
}
throw new Error("无法解析 proposal_id");
}
}
async function main() {
const client = new MultisigClient(RPC_ENDPOINT, CHAIN_ID, CONTRACT_ADDR);
const signer1 = await Wallet.fromMnemonic("mnemonic phrase...");
const signer2 = await Wallet.fromMnemonic("mnemonic phrase...");
const signer3 = await Wallet.fromMnemonic("mnemonic phrase...");
const config = await client.getConfig();
console.log("合约配置:", config);
const voters = await client.getVoters();
console.log(`签名者 (${voters.length}):`, voters);
console.log("\n--- 创建提案 ---");
const proposalId = await client.propose(
signer1, "向社区金库充值", "转入 100 MSG",
[{
bank: {
send: {
to_address: "msg1communitypool...",
amount: [{ denom: "umsg", amount: "100000000000000000000" }],
},
},
}]
);
console.log("\n--- 投票 ---");
await client.vote(signer2, proposalId, "yes");
let proposal = await client.getProposal(proposalId);
console.log(`状态: ${proposal!.status}, 赞成: ${proposal!.yes_weight}/${proposal!.total_weight}`);
await client.vote(signer3, proposalId, "yes");
proposal = await client.getProposal(proposalId);
console.log(`投票后状态: ${proposal!.status}`);
if (proposal!.status === "passed") {
console.log("\n--- 执行提案 ---");
await client.execute(signer1, proposalId);
proposal = await client.getProposal(proposalId);
console.log(`最终状态: ${proposal!.status}`);
}
}
main().catch(console.error);
4.4 命令行完整流程
#!/bin/bash
CHAIN_ID="msg-chain-1"
NODE="https://rpc.msgchain.org"
MULTISIG="msg1multisigcontract..."
SIGNER1="signer1"
SIGNER2="signer2"
SIGNER3="signer3"
echo "=== 查询合约配置 ==="
msg-chain-devkit query wasm contract-state smart "$MULTISIG" \
'{"config":{}}' --node "$NODE" --chain-id "$CHAIN_ID"
echo "=== 查询投票者 ==="
msg-chain-devkit query wasm contract-state smart "$MULTISIG" \
'{"list_voters":{}}' --node "$NODE" --chain-id "$CHAIN_ID"
echo "=== 创建转账提案 ==="
PROPOSAL_RESULT=$(msg-chain-devkit tx wasm execute "$MULTISIG" \
'{
"propose": {
"title": "开发者资助转账",
"description": "向开发者转账 10 MSG",
"msgs": [{
"bank": {
"send": {
"to_address": "msg1developer...",
"amount": [{"denom": "umsg", "amount": "10000000"}]
}
}
}]
}
}' \
--from "$SIGNER1" --gas auto --gas-adjustment 1.4 \
--node "$NODE" --chain-id "$CHAIN_ID" -o json)
echo "提案结果: $PROPOSAL_RESULT"
PROPOSAL_ID=$(echo "$PROPOSAL_RESULT" | jq -r '.raw_log' | grep -oP 'proposal_id: \K\d+')
echo "提案 ID: $PROPOSAL_ID"
echo "=== 签名者 2 投票 ==="
msg-chain-devkit tx wasm execute "$MULTISIG" \
"{\"vote\":{\"proposal_id\":$PROPOSAL_ID,\"vote\":\"yes\"}}" \
--from "$SIGNER2" --gas auto --gas-adjustment 1.3 \
--node "$NODE" --chain-id "$CHAIN_ID"
echo "=== 签名者 3 投票 ==="
msg-chain-devkit tx wasm execute "$MULTISIG" \
"{\"vote\":{\"proposal_id\":$PROPOSAL_ID,\"vote\":\"yes\"}}" \
--from "$SIGNER3" --gas auto --gas-adjustment 1.3 \
--node "$NODE" --chain-id "$CHAIN_ID"
echo "=== 提案状态 ==="
msg-chain-devkit query wasm contract-state smart "$MULTISIG" \
"{\"proposal\":{\"proposal_id\":$PROPOSAL_ID}}" \
--node "$NODE" --chain-id "$CHAIN_ID" | jq
echo "=== 执行提案 ==="
msg-chain-devkit tx wasm execute "$MULTISIG" \
"{\"execute\":{\"proposal_id\":$PROPOSAL_ID}}" \
--from "$SIGNER1" --gas auto --gas-adjustment 1.5 \
--node "$NODE" --chain-id "$CHAIN_ID"
echo "=== 验证结果 ==="
msg-chain-devkit query wasm contract-state smart "$MULTISIG" \
"{\"proposal\":{\"proposal_id\":$PROPOSAL_ID}}" \
--node "$NODE" --chain-id "$CHAIN_ID" | jq '.data.status'
4.5 提案生命周期状态机
┌─────────────┐
│ Pending │
└──────┬──────┘
│ (提案人自动投 Yes)
▼
┌─────────────┐
┌─────────│ Open │──────────┐
│ └──────┬──────┘ │
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Expired │ │ Passed │ │ Rejected │
└────┬─────┘ └──────┬───────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Closed │ │ Executed │ │ Closed │
└──────────┘ └──────────────┘ └──────────┘
5. 前端组件
5.1 React 多签组件
// MultisigDashboard.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { MsgChainClient, Wallet } from '@msg-chain/sdk';
interface MultisigDashboardProps {
rpcEndpoint: string;
chainId: string;
contractAddr: string;
userAddress: string;
}
type ProposalFilter = 'all' | 'open' | 'passed' | 'executed' | 'rejected';
type VoteType = 'yes' | 'no' | 'veto' | 'abstain';
interface Voter {
addr: string;
weight: string;
}
interface Proposal {
id: number;
title: string;
description: string;
status: string;
yes_weight: string;
no_weight: string;
veto_weight: string;
abstain_weight: string;
total_weight: string;
expires: any;
proposer: string;
}
interface Config {
threshold: any;
max_voting_period: any;
total_weight: string;
}
const filterLabels: Record<ProposalFilter, string> = {
all: '全部', open: '投票中', passed: '已通过',
executed: '已执行', rejected: '已拒绝',
};
function shortenAddress(addr: string, chars: number = 10): string {
if (addr.length <= chars * 2) return addr;
return `${addr.slice(0, chars)}...${addr.slice(-chars)}`;
}
const ProposalCard: React.FC<{
proposal: Proposal;
onVote: (id: number, vote: VoteType) => void;
onExecute: (id: number) => void;
onClose: (id: number) => void;
}> = ({ proposal, onVote, onExecute, onClose }) => {
const progress = proposal.total_weight !== '0'
? (parseInt(proposal.yes_weight) / parseInt(proposal.total_weight)) * 100
: 0;
const statusLabels: Record<string, string> = {
open: '投票中', passed: '已通过', rejected: '已拒绝',
executed: '已执行', closed: '已关闭', expired: '已过期',
};
return (
<div className={`proposal-card status-${proposal.status}`}>
<div className="proposal-header">
<span className="proposal-id">#{proposal.id}</span>
<span className="proposal-status">{statusLabels[proposal.status] || proposal.status}</span>
</div>
<h3 className="proposal-title">{proposal.title}</h3>
<p className="proposal-description">{proposal.description}</p>
<div className="proposal-meta">提案人: {shortenAddress(proposal.proposer)}</div>
<div className="vote-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${Math.min(progress, 100)}%` }} />
</div>
<div className="vote-stats">
<span className="yes">赞成: {proposal.yes_weight}</span>
<span className="no">反对: {proposal.no_weight}</span>
<span className="abstain">弃权: {proposal.abstain_weight}</span>
</div>
</div>
{proposal.status === 'open' && (
<div className="vote-actions">
<button onClick={() => onVote(proposal.id, 'yes')} className="btn-yes">赞成</button>
<button onClick={() => onVote(proposal.id, 'no')} className="btn-no">反对</button>
<button onClick={() => onVote(proposal.id, 'veto')} className="btn-veto">强烈反对</button>
<button onClick={() => onVote(proposal.id, 'abstain')} className="btn-abstain">弃权</button>
</div>
)}
{proposal.status === 'passed' && (
<button onClick={() => onExecute(proposal.id)} className="btn-execute">执行提案</button>
)}
{proposal.status === 'expired' && (
<button onClick={() => onClose(proposal.id)} className="btn-close">关闭提案</button>
)}
</div>
);
};
const VoterManager: React.FC<{
voters: Voter[]; config: Config; isOwner: boolean;
onAddVoter?: (addr: string, weight: string) => void;
onRemoveVoter?: (addr: string) => void;
}> = ({ voters, config, isOwner, onAddVoter, onRemoveVoter }) => {
const [showForm, setShowForm] = useState(false);
const [newAddr, setNewAddr] = useState('');
const [newWeight, setNewWeight] = useState('1');
return (
<div className="voter-manager">
<div className="voter-manager-header">
<h3>签名者</h3>
<span>{voters.length} 人 (总权重 {config.total_weight})</span>
{isOwner && <button onClick={() => setShowForm(!showForm)}>+ 添加</button>}
</div>
{showForm && (
<div className="add-voter-form">
<input value={newAddr} onChange={e => setNewAddr(e.target.value)} placeholder="msg1..." />
<input value={newWeight} onChange={e => setNewWeight(e.target.value)} placeholder="权重" />
<button onClick={() => { onAddVoter?.(newAddr, newWeight); setShowForm(false); }}>确认</button>
</div>
)}
{voters.map(v => (
<div key={v.addr} className="voter-row">
<span>{shortenAddress(v.addr)}</span>
<span>{v.weight}</span>
{isOwner && <button onClick={() => onRemoveVoter?.(v.addr)}>✕</button>}
</div>
))}
</div>
);
};
const ProposeForm: React.FC<{
onSubmit: (title: string, desc: string, msgs: object[]) => void;
onCancel: () => void;
}> = ({ onSubmit, onCancel }) => {
const [title, setTitle] = useState('');
const [desc, setDesc] = useState('');
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
return (
<div className="propose-form-overlay">
<div className="propose-form-modal">
<h2>创建提案</h2>
<form onSubmit={e => {
e.preventDefault();
onSubmit(title, desc, [{
bank: { send: { to_address: recipient, amount: [{ denom: 'umsg', amount }] } },
}]);
}}>
<label>标题</label>
<input value={title} onChange={e => setTitle(e.target.value)} required />
<label>描述</label>
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={3} />
<label>收款地址</label>
<input value={recipient} onChange={e => setRecipient(e.target.value)} placeholder="msg1..." required />
<label>金额 (umsg)</label>
<input value={amount} onChange={e => setAmount(e.target.value)} required />
<div className="form-actions">
<button type="button" onClick={onCancel}>取消</button>
<button type="submit">提交</button>
</div>
</form>
</div>
</div>
);
};
export const MultisigDashboard: React.FC<MultisigDashboardProps> = ({
rpcEndpoint, chainId, contractAddr, userAddress,
}) => {
const [client, setClient] = useState<any>(null);
const [config, setConfig] = useState<Config | null>(null);
const [voters, setVoters] = useState<Voter[]>([]);
const [proposals, setProposals] = useState<Proposal[]>([]);
const [filter, setFilter] = useState<ProposalFilter>('all');
const [showForm, setShowForm] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
const init = async () => {
const c = { contractAddr, rpcEndpoint, chainId } as any;
setClient(c);
try {
const [cfg, vrs, props] = await Promise.all([
fetch(`https://rest.msgchain.org/cosmwasm/wasm/v1/contract/${contractAddr}/smart`,
{ method: 'POST', body: JSON.stringify({ data: Buffer.from(JSON.stringify({ config: {} })).toString('base64') }) }),
// In production, use proper SDK client
]);
} finally { setLoading(false); }
};
init();
}, []);
const handlePropose = useCallback(async (title: string, desc: string, msgs: object[]) => {
console.log('Propose:', title, desc, msgs);
setShowForm(false);
}, []);
const handleVote = useCallback(async (id: number, vote: VoteType) => {
console.log('Vote:', id, vote);
}, []);
const filteredProposals = proposals.filter(p => filter === 'all' || p.status === filter);
if (loading) return <div>加载多签钱包...</div>;
return (
<div className="multisig-dashboard">
<div className="dashboard-header">
<h1>多签钱包</h1>
<span>合约: {shortenAddress(contractAddr)}</span>
<button onClick={() => setShowForm(true)}>+ 创建提案</button>
</div>
<div className="dashboard-grid">
<VoterManager voters={voters} config={config!} isOwner={false} />
<div>
<div className="filter-tabs">
{(Object.keys(filterLabels) as ProposalFilter[]).map(f => (
<button key={f} className={`filter-tab ${filter === f ? 'active' : ''}`}
onClick={() => setFilter(f)}>{filterLabels[f]}</button>
))}
</div>
{filteredProposals.map(p => (
<ProposalCard key={p.id} proposal={p}
onVote={handleVote} onExecute={handleVote} onClose={handleVote} />
))}
</div>
</div>
{showForm && <ProposeForm onSubmit={handlePropose} onCancel={() => setShowForm(false)} />}
</div>
);
};
5.2 React 组件样式
.multisig-dashboard {
max-width: 1200px; margin: 0 auto; padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.dashboard-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #e0e0e0;
}
.dashboard-header h1 { font-size: 24px; margin: 0; }
.dashboard-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
.voter-manager {
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px;
}
.voter-manager-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;
}
.voter-row {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px;
}
.voter-addr { font-family: monospace; color: #374151; }
.proposal-card {
background: white; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 16px; margin-bottom: 12px;
}
.proposal-card.status-passed { border-left: 3px solid #10b981; }
.proposal-card.status-rejected { border-left: 3px solid #ef4444; }
.proposal-header { display: flex; justify-content: space-between; margin-bottom: 8px; }
.proposal-title { margin: 0 0 4px; font-size: 16px; font-weight: 500; }
.proposal-description { font-size: 13px; color: #6b7280; margin-bottom: 8px; }
.progress-bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: #10b981; transition: width 0.3s; }
.vote-stats { display: flex; gap: 16px; font-size: 12px; }
.vote-stats .yes { color: #10b981; }
.vote-stats .no { color: #ef4444; }
.vote-actions { display: flex; gap: 8px; }
.btn-yes { background: #ecfdf5; color: #059669; border: 1px solid #a7f3d0; }
.btn-no { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
.btn-veto { background: #fff7ed; color: #ea580c; border: 1px solid #fed7aa; }
.btn-abstain { background: #f9fafb; color: #6b7280; border: 1px solid #e5e7eb; }
.btn-execute { background: #059669; color: white; border: none; padding: 8px 20px; border-radius: 6px; }
button { cursor: pointer; padding: 6px 14px; border-radius: 6px; font-size: 13px; }
.filter-tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.filter-tab { border: 1px solid #e5e7eb; background: transparent; color: #374151; }
.filter-tab.active { background: #4f46e5; color: white; border-color: #4f46e5; }
.propose-form-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.propose-form-modal {
background: white; border-radius: 12px; padding: 32px; max-width: 500px; width: 100%;
}
.form-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px; }
6. 安全最佳实践
6.1 阈值选择
合理的阈值设置是多签安全性的核心:
| 签名者数 | 推荐阈值 | 安全级别 | 说明 |
|---|---|---|---|
| 3 | 2/3 | 基础 | 适合小额运营资金 |
| 5 | 3/5 | 标准 | 推荐,兼顾安全与可用性 |
| 7 | 4/7 | 较高 | 适合大额资金管理 |
| 9 | 5/9 | 高 | 适合协议金库 |
原则:
- 阈值应 ≥ 总签名者数的 50% + 1
- 3/5 是最佳实践平衡点
- 避免 1/1(退化到单签)
- 避免 n/n(任何一个签名者失联都会导致锁死)
6.2 签名者密钥管理
// 密钥轮换建议
// 1. 使用硬件钱包存储签名者密钥
// 2. 实施社会恢复机制
// 3. 定期轮换密钥(每季度)
pub fn rotate_key_checks() -> Vec<String> {
vec![
"每个签名者使用独立的密钥".to_string(),
"密钥保存在硬件钱包中(Ledger/Trezor)".to_string(),
"备份助记词并分散存储在安全地点".to_string(),
"至少两个签名者不在同一地理位置".to_string(),
"使用不同的密钥派生路径".to_string(),
]
}
6.3 提案消息验证
// 提案消息安全检查
pub fn validate_proposal_msgs(msgs: &[CosmosMsg<Empty>]) -> StdResult<()> {
for msg in msgs {
match msg {
CosmosMsg::Bank(bank_msg) => {
match bank_msg {
BankMsg::Send { to_address, amount } => {
// 验证地址格式
if !to_address.starts_with("msg1") {
return Err(StdError::generic_err("Invalid recipient address"));
}
// 验证金额不为零
for coin in amount {
if coin.amount.is_zero() {
return Err(StdError::generic_err("Zero amount not allowed"));
}
}
}
_ => return Err(StdError::generic_err("Unsupported bank message type")),
}
}
CosmosMsg::Wasm(wasm_msg) => {
match wasm_msg {
WasmMsg::Execute { contract_addr, .. } => {
// 验证合约地址
if !contract_addr.starts_with("msg1") {
return Err(StdError::generic_err("Invalid contract address"));
}
}
WasmMsg::Migrate { .. } => {
// 迁移操作需要额外安全检查
return Err(StdError::generic_err(
"Migration requires additional authorization",
));
}
_ => {}
}
}
_ => {
return Err(StdError::generic_err("Unsupported message type in proposal"));
}
}
}
Ok(())
}
6.4 紧急暂停机制
// 紧急暂停状态
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct EmergencyState {
pub paused: bool,
pub pause_admin: Addr,
pub unpause_threshold: Threshold,
pub pause_reason: Option<String>,
}
pub const EMERGENCY: Item<EmergencyState> = Item::new("emergency");
pub fn execute_emergency_pause(
deps: DepsMut,
_env: Env,
info: MessageInfo,
reason: String,
) -> StdResult<Response> {
let state = EMERGENCY.load(deps.storage)?;
if info.sender != state.pause_admin {
return Err(StdError::generic_err("Unauthorized: not pause admin"));
}
EMERGENCY.save(deps.storage, &EmergencyState {
paused: true,
pause_admin: state.pause_admin,
unpause_threshold: state.unpause_threshold,
pause_reason: Some(reason),
})?;
Ok(Response::new()
.add_attribute("method", "emergency_pause")
.add_attribute("reason", reason))
}
pub fn execute_emergency_unpause(
deps: DepsMut,
_env: Env,
info: MessageInfo,
) -> StdResult<Response> {
let state = EMERGENCY.load(deps.storage)?;
if !state.paused {
return Err(StdError::generic_err("Contract is not paused"));
}
// 仅 pause_admin 可解暂停,或通过多签投票
if info.sender != state.pause_admin {
return Err(StdError::generic_err("Unauthorized: not pause admin"));
}
EMERGENCY.save(deps.storage, &EmergencyState {
paused: false,
..state
})?;
Ok(Response::new()
.add_attribute("method", "emergency_unpause"))
}
6.5 安全清单
/// 多签合约安全清单
///
/// [x] 防止重复投票
/// [x] 验证提案者身份
/// [x] 过期提案自动失效
/// [x] 已执行提案不可重复执行
/// [x] 消息数量限制
/// [x] 描述长度限制
/// [x] 阈值校验
/// [ ] 时间锁
/// [ ] 紧急暂停
/// [ ] 提案消息白名单
/// [ ] 签名者数量下限
/// [ ] Gas 上限控制
/// [ ] 重入防护
/// [ ] 事件日志完善
部署前检查清单:
- 所有签名者地址确认正确(建议在测试网先验证)
- 阈值设置合理(不低于 50% + 1)
- 投票期设置合理(7 天为推荐值)
- 合约管理员设置正确
- 测试多签全部流程(提案 - 投票 - 执行 - 关闭)
- 确认所有签名者都能正常连接 MSG Chain
- 备份合约实例化参数
7. 集成场景
7.1 DAO 财库管理
// DAO 治理 + 多签财库集成
//
// 工作流程:
// 1. DAO 成员投票通过治理提案
// 2. DAO 治理合约调用多签财库的 Propose
// 3. 多签签名者审核并投票
// 4. 达到阈值后执行
use cosmwasm_std::CosmosMsg;
// DAO 治理合约 -> 多签财库 的调用示例
pub fn create_dao_treasury_proposal(
dao_contract: &Addr,
multisig_contract: &Addr,
to_address: &str,
amount: Uint128,
) -> CosmosMsg {
CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: multisig_contract.to_string(),
msg: to_binary(&ExecuteMsg::Propose {
title: "DAO 治理执行:资金拨付".to_string(),
description: format!(
"根据 DAO 提案 #{} 执行资金拨付",
"PROPOSAL_ID"
),
msgs: vec![CosmosMsg::Bank(BankMsg::Send {
to_address: to_address.to_string(),
amount: coins(amount.u128(), "umsg"),
})],
earliest: None,
})?,
funds: vec![],
})
}
DAO 财库合约示例
// dao_treasury.rs - DAO 财库多签封装
use cosmwasm_std::{
entry_point, to_binary, Binary, CosmosMsg, Deps, DepsMut, Env,
MessageInfo, Response, StdResult, WasmMsg,
};
pub struct DAOTreasuryConfig {
pub governance_addr: Addr,
pub multisig_addr: Addr,
pub min_delay: Duration,
}
pub fn create_treasury_proposal(
deps: DepsMut,
_env: Env,
info: MessageInfo,
msgs: Vec<CosmosMsg>,
title: String,
description: String,
) -> StdResult<Response> {
// 验证调用者是否为 DAO 治理合约
let config = DAO_TREASURY_CONFIG.load(deps.storage)?;
if info.sender != config.governance_addr {
return Err(StdError::generic_err("Unauthorized: only governance contract can propose"));
}
// 通过多签合约创建提案
let propose_msg = CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: config.multisig_addr.to_string(),
msg: to_binary(&ExecuteMsg::Propose {
title,
description,
msgs,
earliest: None,
})?,
funds: vec![],
});
Ok(Response::new()
.add_message(propose_msg)
.add_attribute("method", "create_treasury_proposal"))
}
7.2 跨合约多签调用
// 多签跨合约调用示例
interface CrossContractCall {
contractAddr: string;
msg: object;
funds?: Coin[];
}
async function multisigContractCall(
client: MultisigClient,
wallet: Wallet,
calls: CrossContractCall[],
title: string,
description: string,
) {
const msgs = calls.map(call => ({
wasm: {
execute: {
contract_addr: call.contractAddr,
msg: Buffer.from(JSON.stringify(call.msg)).toString("base64"),
funds: call.funds || [],
},
},
}));
return client.propose(wallet, title, description, msgs);
}
// 示例:同时调用多个合约
const batchCalls = [
{
contractAddr: "msg1stakingcontract...",
msg: { delegate: { validator: "msgvaloper1...", amount: "1000000" } },
},
{
contractAddr: "msg1lendingcontract...",
msg: { deposit: { amount: "5000000" } },
},
];
await multisigContractCall(
client, wallet, batchCalls,
"批量 DeFi 操作",
"同时执行质押和借贷存款操作"
);
7.3 集成宪章引擎
// 宪章引擎 + 多签集成
// 某些高价值操作需要宪章规则触发多签验证
pub enum ConstitutionAction {
TreasuryWithdrawal {
amount: Uint128,
reason: String,
},
ContractUpgrade {
code_id: u64,
reason: String,
},
EmergencyAction {
action_type: String,
parameters: Binary,
},
}
pub fn constitution_multisig_check(
action: &ConstitutionAction,
constitution: &Constitution,
) -> StdResult<bool> {
match action {
ConstitutionAction::TreasuryWithdrawal { amount, .. } => {
// 小额:2/3 多签
if amount < Uint128::new(1_000_000_000_000_000_000u128) { // 1000 MSG
return Ok(true); // 标准多签阈值
}
// 大额:4/5 多签 + 时间锁 7 天
return Ok(false); // 需要更严格的配置
}
ConstitutionAction::ContractUpgrade { .. } => {
// 合约升级需要较高阈值
return Ok(false);
}
ConstitutionAction::EmergencyAction { .. } => {
// 紧急操作可以降低阈值
return Ok(true);
}
}
}
7.4 Agent MPC 钱包对比与选择
| 维度 | CW3 多签 | Agent MPC 钱包 | 推荐场景 |
|---|---|---|---|
| 部署成本 | 高(合约开发部署) | 低(直接使用 SDK) | MPC 适合快速启动 |
| 透明度 | 完全链上透明 | 签名过程不公开 | 合规需求选 CW3 |
| Gas 成本 | O(n) 投票交易 | 单笔交易 | 高频操作选 MPC |
| 签名者管理 | 合约内管理 | 配置化管理 | 需要审计选 CW3 |
| 可编程性 | 完全可编程 | 策略引擎受限 | 复杂逻辑选 CW3 |
| 恢复机制 | 合约内实现 | 外部配置 | 安全要求高选 CW3 |
选择建议:
- DAO 财库 → CW3 多签(透明、可审计、可编程)
- 个人资产管理 → Agent MPC(便捷、低成本)
- 企业合规 → CW3 多签 + 时间锁
- 高频交易 → Agent MPC 或混合方案
7.5 混合架构:CW3 + MPC
// 混合多签架构
// 采用 CW3 作为最终执行层,MPC 作为签名聚合层
pub struct HybridMultisigConfig {
pub cw3_contract: Addr, // CW3 合约地址
pub mpc_agent: Addr, // MPC Agent 地址
pub mpc_threshold: u32, // MPC 组阈值
pub mpc_members: Vec<Addr>, // MPC 组成员
}
// 使用 MPC 签名聚合后,通过 CW3 验证并执行
pub fn mpc_aggregated_execution(
deps: DepsMut,
env: Env,
info: MessageInfo,
proposal_id: u64,
mpc_signatures: Vec<Binary>,
) -> StdResult<Response> {
// 1. 验证 MPC 签名聚合
let config = HYBRID_CONFIG.load(deps.storage)?;
if info.sender != config.mpc_agent {
return Err(StdError::generic_err("Unauthorized: not MPC agent"));
}
// 2. 验证 MPC 签名数量
if (mpc_signatures.len() as u32) < config.mpc_threshold {
return Err(StdError::generic_err("Insufficient MPC signatures"));
}
// 3. 执行 CW3 提案
let proposal = PROPOSALS.load(deps.storage, proposal_id)?;
if proposal.status != ProposalStatus::Passed {
return Err(StdError::generic_err("Proposal not passed in CW3"));
}
proposal.status = ProposalStatus::Executed;
PROPOSALS.save(deps.storage, proposal_id, &proposal)?;
Ok(Response::new()
.add_messages(proposal.msgs)
.add_attribute("method", "hybrid_execute")
.add_attribute("proposal_id", proposal_id.to_string())
.add_attribute("mpc_signatures", mpc_signatures.len().to_string()))
}
8. 附录
8.1 Cargo.toml 配置
[package]
name = "cw3-fixed-multisig"
version = "1.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = []
library = []
[dependencies]
cosmwasm-std = "1.5"
cosmwasm-storage = "1.5"
cw-storage-plus = "1.2"
cw2 = "1.1"
cw-utils = "1.0"
cw1 = "0.17"
schemars = "0.8"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
[dev-dependencies]
cosmwasm-schema = "1.5"
cw-multi-test = "1.1"
[profile.release]
opt-level = 3
debug = false
rpath = false
lto = true
debug-assertions = false
codegen-units = 1
panic = "abort"
overflow-checks = true
8.2 部署与验证命令
# 编译
RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown
# 优化
wasm-opt -Os target/wasm32-unknown-unknown/release/cw3_fixed_multisig.wasm \
-o cw3_fixed_multisig_optimized.wasm
# 获取代码 hash
sha256sum cw3_fixed_multisig_optimized.wasm
# 上传到 MSG Chain
msg-chain-devkit tx wasm store cw3_fixed_multisig_optimized.wasm \
--from deployer --gas auto --gas-adjustment 1.3
# 验证代码
msg-chain-devkit query wasm code CODE_ID
# 实例化
msg-chain-devkit tx wasm instantiate CODE_ID '{"signers":[...]}' \
--from admin --label "my-multisig" --admin msg1admin...
# 合约验证(通过 CosmWasm 的 verify_attestation)
msg-chain-devkit query wasm contract-state smart CONTRACT_ADDR '{"config":{}}'
8.3 Gas 成本估算
| 操作 | Gas 消耗 (估计) | 费用 (MSG) |
|---|---|---|
| 实例化 | 200,000 - 400,000 | 0.0002 - 0.0004 |
| 创建提案 | 150,000 - 300,000 | 0.00015 - 0.0003 |
| 投票 | 100,000 - 200,000 | 0.0001 - 0.0002 |
| 执行(含消息) | 200,000 - 2,000,000 | 0.0002 - 0.002 |
| 关闭 | 80,000 - 150,000 | 0.00008 - 0.00015 |
| 查询 | 10,000 - 50,000 | 0.00001 - 0.00005 |
注意:Gas 消耗取决于提案中包含的消息数量和复杂度。MSG Chain Gas 价格为 1,000,000,000 attoMSG/gas,执行包含多条消息的提案时请预留充足 Gas。
8.4 错误代码表
| 错误 | 代码 | 说明 |
|---|---|---|
| Unauthorized: not a voter | 1001 | 非授权签名者操作 |
| Proposal has expired | 1002 | 提案已过投票期 |
| Already voted | 1003 | 重复投票 |
| Proposal not open | 1004 | 提案非投票状态 |
| Proposal not passed | 1005 | 提案未通过无法执行 |
| Already executed | 1006 | 提案已执行 |
| Already closed | 1007 | 提案已关闭 |
| Not expired yet | 1008 | 提案未到期不能关闭 |
| Empty proposal | 1009 | 提案消息为空 |
| Description too long | 1010 | 描述超长 |
| Too many messages | 1011 | 超过最大消息数 |
| Voter not found | 1012 | 签名者不存在 |
| Voter already exists | 1013 | 签名者已存在 |
| Zero weight | 1014 | 权重为零不允许 |
8.5 相关资源
- CosmWasm 官方文档: https://docs.cosmwasm.com
- CW3 标准规范: https://github.com/CosmWasm/cw-plus/tree/main/packages/cw3
- cw3-fixed-multisig 源码: https://github.com/CosmWasm/cw-plus/tree/main/contracts/cw3-fixed-multisig
- cw3-flex-multisig 源码: https://github.com/CosmWasm/cw-plus/tree/main/contracts/cw3-flex-multisig
- MSG Chain 开发者门户: https://docs.msgchain.org
- msg-chain-devkit CLI: MSG Chain 官方命令行工具
- MSG Chain 测试网水龙头: https://faucet.msgchain.org
8.6 常见问题
Q: 签名者丢失了密钥怎么办?
A: 如果签名者丢失密钥,且多签未达到阈值,则资金永久锁定。建议:
- 选择 3/5 而非 5/5 以防止单点故障
- 定期备份密钥
- 考虑使用社会恢复机制
Q: 如何更改多签签名者?
A: 使用 cw3_fixed_multisig 不能更改签名者。如需要动态成员管理,请使用 cw3_flex_multisig。
Q: 提案通过后多久可以执行?
A: 通过后可在投票期内随时执行。逾期未执行则提案过期,需重新创建。
Q: 是否可以取消已提交的投票?
A: 不可以。一旦投票即不可撤销。创建提案时请仔细审核消息内容。
Q: MSG Chain 上的最小多签阈值是多少?
A: 最小阈值为 1(即单签,不推荐),实际应至少为总签名者数的 50% + 1。
Q: 多签合约可以持有哪些资产?
A: 多签合约可以持有任何 MSG Chain 原生代币(umsg)以及 CW20 代币。对 CW20 代币的操作需要通过 Wasm 消息进行。
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
