时间锁与代币锁仓 Vesting 实现 — MSG Chain 完整指南
适用链: MSG Chain (
msg-chain-1, Bech32:msg, coinType:118)
原生代币:umsg(1 MSG = 10¹⁸ umsg)
智能合约: CosmWasm (cw20 标准)
出块时间: ~5秒 (年均 ~6,300,000 区块)
主网状态: No-Go
Gas: 1,000,000,000 attoMSG/gas
目录
1. 概述
1.1 为什么需要锁仓与 Vesting
在区块链项目中,代币分发机制直接影响项目的长期健康发展。锁仓(Timelock)与代币归属(Vesting)是两种核心工具:
| 机制 | 目的 | 适用对象 |
|---|---|---|
| Timelock | 延迟代币或合约操作的执行时间 | 团队分配、合约升级、治理提案 |
| Vesting | 按时间表逐步释放代币 | 团队成员、顾问、投资者、空投 |
MSG Chain 代币经济背景:
- 区块奖励随排放计划递减
- 国库分配:30% 排放量
- 代理激励:20% 排放量
- Vesting 对团队/顾问/投资者代币分发至关重要
1.2 Timelock vs Vesting 核心区别
Timelock: [==== 锁仓期 ====] 一次性全部释放
|
到期后立即100%可用
Vesting with cliff: [== cliff ==][==== 线性释放 ====]
| |
cliff到期后 按区块逐渐释放
部分可用
Timelock 适用于:
- 合约执行延迟(如治理提案通过后的等待期)
- 代币在特定日期前完全锁定
Vesting 适用于:
- 团队激励(留住人才,防止抛售)
- 投资者保护(分期释放,市场更稳定)
- 空投线性解锁
1.3 MSG Chain 上的合约部署流程
1. 编写 CosmWasm 合约
2. 编译为 .wasm
3. 上传到 MSG Chain: msgd tx wasm store
4. 实例化合约: msgd tx wasm instantiate
5. 交互执行: msgd tx wasm execute
1.4 术语表
| 中文 | English | 说明 |
|---|---|---|
| 时间锁 | Timelock | 延迟到未来某个时间点才能执行操作 |
| 锁仓 | Vesting | 按时间表逐步释放代币 |
| 悬崖期 | Cliff | 在此之前无法释放任何代币 |
| 线性释放 | Linear Vesting | 等速率持续释放 |
| 受益人 | Beneficiary | 最终接收代币的地址 |
| 可撤销 | Revocable | 管理员可以收回未释放代币 |
| 代币归属计划 | Vesting Schedule | 完整的释放时间表 |
| 释放 | Release | 从合约领取已解锁代币 |
| 工厂合约 | Factory | 批量创建和管理 Vesting 的合约 |
2. Timelock 控制器实现
2.1 设计原理
TimelockController 是受 Compound Governance 和 OpenZeppelin 启发的合约。核心思想:敏感操作必须经过延迟后才能执行。
架构:
Proposer → 提交操作 (schedule)
↓
等待期间 (min_delay)
↓
Executor → 执行操作 (execute)
↓
目标合约收到调用
2.2 角色定义
| 角色 | 权限 |
|---|---|
| Admin | 设置延迟、管理角色、紧急暂停 |
| Proposer | 提交和取消待执行操作 |
| Executor | 在延迟期满后执行操作 |
2.3 完整 Rust 代码
// src/contracts/timelock.rs
use cosmwasm_std::{
addr_validate, entry_point, to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut,
Env, MessageInfo, Order, Response, StdError, StdResult, Timestamp, WasmMsg,
};
use cw2::set_contract_version;
use cw_storage_plus::{Item, Map};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const CONTRACT_NAME: &str = "msgchain-timelock";
const CONTRACT_VERSION: &str = "1.0.0";
// ── 状态 ──────────────────────────────────────────────────────────────
const MIN_DELAY: Item<u64> = Item::new("min_delay");
const ADMIN: Item<Addr> = Item::new("admin");
const PENDING_ADMIN: Item<Option<Addr>> = Item::new("pending_admin");
const EXECUTOR: Item<Addr> = Item::new("executor");
pub const SCHEDULED_OPERATIONS: Map<&[u8], Timestamp> = Map::new("scheduled");
// ── 消息类型 ──────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub min_delay: u64,
pub admin: String,
pub executor: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
Schedule { target: String, msg: Binary, delay: u64 },
Cancel { operation_id: String },
Execute { target: String, msg: Binary },
UpdateMinDelay { new_delay: u64 },
TransferAdmin { new_admin: String },
AcceptAdmin {},
UpdateExecutor { new_executor: String },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
MinDelay {},
Admin {},
Executor {},
IsScheduled { operation_id: String },
GetExecutionTime { operation_id: String },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MinDelayResponse { pub min_delay: u64 }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct AdminResponse { pub admin: String }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ExecutorResponse { pub executor: String }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct IsScheduledResponse { pub scheduled: bool }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ExecutionTimeResponse { pub execution_time: Option<u64> }
// ── 入口点 ────────────────────────────────────────────────────────────
#[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 admin = addr_validate(deps.api, &msg.admin)?;
let executor = addr_validate(deps.api, &msg.executor)?;
ADMIN.save(deps.storage, &admin)?;
PENDING_ADMIN.save(deps.storage, &None)?;
EXECUTOR.save(deps.storage, &executor)?;
MIN_DELAY.save(deps.storage, &msg.min_delay)?;
Ok(Response::new()
.add_attribute("method", "instantiate")
.add_attribute("admin", &msg.admin)
.add_attribute("executor", &msg.executor)
.add_attribute("min_delay", msg.min_delay.to_string()))
}
#[entry_point]
pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> StdResult<Response> {
match msg {
ExecuteMsg::Schedule { target, msg, delay } => execute_schedule(deps, env, info, target, msg, delay),
ExecuteMsg::Cancel { operation_id } => execute_cancel(deps, env, info, operation_id),
ExecuteMsg::Execute { target, msg } => execute_execute(deps, env, info, target, msg),
ExecuteMsg::UpdateMinDelay { new_delay } => execute_update_min_delay(deps, env, info, new_delay),
ExecuteMsg::TransferAdmin { new_admin } => execute_transfer_admin(deps, env, info, new_admin),
ExecuteMsg::AcceptAdmin {} => execute_accept_admin(deps, env, info),
ExecuteMsg::UpdateExecutor { new_executor } => execute_update_executor(deps, env, info, new_executor),
}
}
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::MinDelay {} => to_binary(&query_min_delay(deps)?),
QueryMsg::Admin {} => to_binary(&query_admin(deps)?),
QueryMsg::Executor {} => to_binary(&query_executor(deps)?),
QueryMsg::IsScheduled { operation_id } => to_binary(&query_is_scheduled(deps, operation_id)?),
QueryMsg::GetExecutionTime { operation_id } => to_binary(&query_execution_time(deps, operation_id)?),
}
}
// ── 操作 ID 生成 ─────────────────────────────────────────────────────
pub fn compute_operation_id(target: &str, msg: &Binary) -> String {
let hash = sha256::digest(format!("{}{}", target, msg.as_slice()));
hash
}
// ── 调度 ──────────────────────────────────────────────────────────────
fn execute_schedule(
deps: DepsMut, _env: Env, info: MessageInfo,
target: String, msg: Binary, delay: u64,
) -> StdResult<Response> {
let admin = ADMIN.load(deps.storage)?;
if info.sender != admin {
return Err(StdError::generic_err("Unauthorized: not admin"));
}
let min_delay = MIN_DELAY.load(deps.storage)?;
if delay < min_delay {
return Err(StdError::generic_err(format!("Delay too short: {} < {}", delay, min_delay)));
}
let operation_id = compute_operation_id(&target, &msg);
let scheduled_time = SCHEDULED_OPERATIONS.may_load(deps.storage, operation_id.as_bytes())?;
if scheduled_time.is_some() {
return Err(StdError::generic_err("Operation already scheduled"));
}
let execution_time = _env.block.time.plus_seconds(delay);
SCHEDULED_OPERATIONS.save(deps.storage, operation_id.as_bytes(), &execution_time)?;
Ok(Response::new()
.add_attribute("method", "schedule")
.add_attribute("operation_id", &operation_id)
.add_attribute("target", &target)
.add_attribute("execution_time", execution_time.to_string()))
}
// ── 取消 ──────────────────────────────────────────────────────────────
fn execute_cancel(
deps: DepsMut, _env: Env, info: MessageInfo, operation_id: String,
) -> StdResult<Response> {
let admin = ADMIN.load(deps.storage)?;
if info.sender != admin {
return Err(StdError::generic_err("Unauthorized: not admin"));
}
let scheduled_time = SCHEDULED_OPERATIONS.may_load(deps.storage, operation_id.as_bytes())?;
if scheduled_time.is_none() {
return Err(StdError::generic_err("Operation not found"));
}
SCHEDULED_OPERATIONS.remove(deps.storage, operation_id.as_bytes());
Ok(Response::new()
.add_attribute("method", "cancel")
.add_attribute("operation_id", &operation_id))
}
// ── 执行 ──────────────────────────────────────────────────────────────
fn execute_execute(
deps: DepsMut, env: Env, info: MessageInfo, target: String, msg: Binary,
) -> StdResult<Response> {
let executor = EXECUTOR.load(deps.storage)?;
if info.sender != executor {
return Err(StdError::generic_err("Unauthorized: not executor"));
}
let operation_id = compute_operation_id(&target, &msg);
let execution_time = SCHEDULED_OPERATIONS.load(deps.storage, operation_id.as_bytes())?;
if env.block.time < execution_time {
return Err(StdError::generic_err(format!(
"Operation not ready: current time {} < execution time {}", env.block.time, execution_time
)));
}
SCHEDULED_OPERATIONS.remove(deps.storage, operation_id.as_bytes());
let target_addr = addr_validate(deps.api, &target)?;
let execute_msg = CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: target_addr.to_string(),
msg: msg.clone(),
funds: vec![],
});
Ok(Response::new()
.add_message(execute_msg)
.add_attribute("method", "execute")
.add_attribute("operation_id", &operation_id)
.add_attribute("target", &target))
}
// ── 管理函数 ──────────────────────────────────────────────────────────
fn execute_update_min_delay(deps: DepsMut, _env: Env, info: MessageInfo, new_delay: u64) -> StdResult<Response> {
let admin = ADMIN.load(deps.storage)?;
if info.sender != admin { return Err(StdError::generic_err("Unauthorized: not admin")); }
MIN_DELAY.save(deps.storage, &new_delay)?;
Ok(Response::new().add_attribute("method", "update_min_delay").add_attribute("new_delay", new_delay.to_string()))
}
fn execute_transfer_admin(deps: DepsMut, _env: Env, info: MessageInfo, new_admin: String) -> StdResult<Response> {
let admin = ADMIN.load(deps.storage)?;
if info.sender != admin { return Err(StdError::generic_err("Unauthorized: not admin")); }
let new_admin_addr = addr_validate(deps.api, &new_admin)?;
PENDING_ADMIN.save(deps.storage, &Some(new_admin_addr))?;
Ok(Response::new().add_attribute("method", "transfer_admin").add_attribute("pending_admin", &new_admin))
}
fn execute_accept_admin(deps: DepsMut, _env: Env, info: MessageInfo) -> StdResult<Response> {
let pending = PENDING_ADMIN.load(deps.storage)?;
match pending {
Some(pending_admin) => {
if info.sender != pending_admin { return Err(StdError::generic_err("Unauthorized: only pending admin can accept")); }
ADMIN.save(deps.storage, &pending_admin)?;
PENDING_ADMIN.save(deps.storage, &None)?;
Ok(Response::new().add_attribute("method", "accept_admin").add_attribute("new_admin", pending_admin.to_string()))
}
None => Err(StdError::generic_err("No pending admin transfer")),
}
}
fn execute_update_executor(deps: DepsMut, _env: Env, info: MessageInfo, new_executor: String) -> StdResult<Response> {
let admin = ADMIN.load(deps.storage)?;
if info.sender != admin { return Err(StdError::generic_err("Unauthorized: not admin")); }
let new_executor_addr = addr_validate(deps.api, &new_executor)?;
EXECUTOR.save(deps.storage, &new_executor_addr)?;
Ok(Response::new().add_attribute("method", "update_executor").add_attribute("new_executor", &new_executor))
}
// ── 查询函数 ──────────────────────────────────────────────────────────
fn query_min_delay(deps: Deps) -> StdResult<MinDelayResponse> {
let min_delay = MIN_DELAY.load(deps.storage)?;
Ok(MinDelayResponse { min_delay })
}
fn query_admin(deps: Deps) -> StdResult<AdminResponse> {
let admin = ADMIN.load(deps.storage)?;
Ok(AdminResponse { admin: admin.to_string() })
}
fn query_executor(deps: Deps) -> StdResult<ExecutorResponse> {
let executor = EXECUTOR.load(deps.storage)?;
Ok(ExecutorResponse { executor: executor.to_string() })
}
fn query_is_scheduled(deps: Deps, operation_id: String) -> StdResult<IsScheduledResponse> {
let scheduled = SCHEDULED_OPERATIONS.may_load(deps.storage, operation_id.as_bytes())?.is_some();
Ok(IsScheduledResponse { scheduled })
}
fn query_execution_time(deps: Deps, operation_id: String) -> StdResult<ExecutionTimeResponse> {
let execution_time = SCHEDULED_OPERATIONS.may_load(deps.storage, operation_id.as_bytes())?;
Ok(ExecutionTimeResponse { execution_time: execution_time.map(|t| t.seconds()) })
}
// ── 测试 ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
use cosmwasm_std::{from_binary, Addr};
fn setup_contract(deps: DepsMut) {
let msg = InstantiateMsg { min_delay: 3600, admin: "admin".to_string(), executor: "executor".to_string() };
let info = mock_info("deployer", &[]);
let env = mock_env();
instantiate(deps, env, info, msg).unwrap();
}
#[test]
fn test_instantiate() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
assert_eq!(query_min_delay(deps.as_ref()).unwrap().min_delay, 3600);
assert_eq!(query_admin(deps.as_ref()).unwrap().admin, "admin");
assert_eq!(query_executor(deps.as_ref()).unwrap().executor, "executor");
}
#[test]
fn test_schedule_operation() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("admin", &[]);
let msg_exec = ExecuteMsg::Schedule {
target: "contract123".to_string(), msg: Binary::from(b"some_data".as_slice()), delay: 7200,
};
let res = execute(deps.as_mut(), env, info, msg_exec).unwrap();
assert_eq!(res.attributes[0].value, "schedule");
}
#[test]
fn test_schedule_delay_too_short() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let env = mock_env();
let info = mock_info("admin", &[]);
let msg_exec = ExecuteMsg::Schedule {
target: "contract123".to_string(), msg: Binary::from(b"data".as_slice()), delay: 100,
};
let err = execute(deps.as_mut(), env, info, msg_exec).unwrap_err();
assert!(err.to_string().contains("Delay too short"));
}
#[test]
fn test_cancel_operation() {
let mut deps = mock_dependencies();
setup_contract(deps.as_mut());
let op_id = compute_operation_id("contract123", &Binary::from(b"data".as_slice()));
let env = mock_env();
let info = mock_info("admin", &[]);
execute(deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::Schedule {
target: "contract123".to_string(), msg: Binary::from(b"data".as_slice()), delay: 7200,
}).unwrap();
let res = execute(deps.as_mut(), env, info, ExecuteMsg::Cancel { operation_id: op_id.clone() }).unwrap();
assert_eq!(res.attributes[0].value, "cancel");
}
}
2.4 部署与交互
# 编译
RUSTFLAGS='-C link-arg=-s' cargo wasm
cp target/wasm32-unknown-unknown/release/timelock.wasm .
# 上传到 MSG Chain
msgd tx wasm store timelock.wasm \
--from deployer \
--gas auto \
--gas-prices 1000000000attoMSG \
--gas-adjustment 1.3 \
--chain-id msg-chain-1 \
--node https://rpc.msgchain.org:443 \
-y
# 实例化
CODE_ID=1
msgd tx wasm instantiate $CODE_ID \
'{
"min_delay": 86400,
"admin": "msg1admin...",
"executor": "msg1executor..."
}' \
--label "timelock-v1" \
--from deployer \
--gas auto \
--gas-prices 1000000000attoMSG \
--gas-adjustment 1.3 \
--chain-id msg-chain-1 \
-y
2.5 使用场景示例
# 调度一个操作(例如在延迟后给地址转账)
msgd tx wasm execute TIMELOCK_CONTRACT \
'{"schedule":{"target":"CW20_TOKEN_CONTRACT","msg":"eyJ0cmFuc2ZlciI6eyJyZWNpcGllbnQiOiJtc2cx...","delay":172800}}' \
--from admin --gas auto --gas-prices 1000000000attoMSG -y
# 执行(2天后)
msgd tx wasm execute TIMELOCK_CONTRACT \
'{"execute":{"target":"CW20_TOKEN_CONTRACT","msg":"eyJ0cmFuc2ZlciI6eyJyZWNpcGllbnQiOiJtc2cx..."}}' \
--from executor --gas auto --gas-prices 1000000000attoMSG -y
3. TokenVesting 合约实现
3.1 设计原理
TokenVesting 合约持有 CW20 代币并按照预设的时间表释放给受益人。关键参数:
^ 可释放量
|
100% | ────
| /
| /
| /
| /
| /
| /
| /
| /
20% |/ ← cliff 到期点
|
+-----+-------------------→ 时间
Cliff Linear Vesting
3.2 合约分类
| 类型 | 特点 | 适用场景 |
|---|---|---|
| 简单 Vesting | 不可撤销,到期逐步释放 | 空投、公开销售 |
| 可撤销 Vesting | 管理员可收回未释放部分 | 团队、顾问(业绩不达标) |
| 可暂停 Vesting | 发生安全事件时可暂停 | 大额投资者、国库分配 |
3.3 完整 Rust 代码
// src/contracts/token_vesting.rs
use cosmwasm_std::{
addr_validate, entry_point, to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut,
Env, MessageInfo, Response, StdError, StdResult, Timestamp, Uint128, WasmMsg,
};
use cw2::set_contract_version;
use cw20::{Cw20ExecuteMsg, BalanceResponse};
use cw_storage_plus::Item;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const CONTRACT_NAME: &str = "msgchain-token-vesting";
const CONTRACT_VERSION: &str = "1.0.0";
pub const BLOCKS_PER_YEAR: u64 = 6_300_000;
pub const BLOCKS_PER_MONTH: u64 = 525_000;
pub const BLOCKS_PER_DAY: u64 = 17_280;
const TOKEN: Item<Addr> = Item::new("token");
const BENEFICIARY: Item<Addr> = Item::new("beneficiary");
const CLIFF_DURATION: Item<u64> = Item::new("cliff_duration");
const TOTAL_DURATION: Item<u64> = Item::new("total_duration");
const START_TIME: Item<Timestamp> = Item::new("start_time");
const RELEASED_AMOUNT: Item<Uint128> = Item::new("released_amount");
const TOTAL_AMOUNT: Item<Uint128> = Item::new("total_amount");
const ADMIN: Item<Option<Addr>> = Item::new("admin");
const REVOKED: Item<bool> = Item::new("revoked");
const REVOCABLE: Item<bool> = Item::new("revocable");
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub token: String,
pub beneficiary: String,
pub cliff_duration: u64,
pub total_duration: u64,
pub start_time: Option<u64>,
pub revocable: bool,
pub admin: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
Release {},
Revoke {},
Fund {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
ReleasedAmount {},
TotalAmount {},
PendingAmount {},
VestedAmount {},
VestingInfo {},
Beneficiary {},
CliffDuration {},
TotalDuration {},
ElapsedTime {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ReleasedAmountResponse { pub released: Uint128 }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct TotalAmountResponse { pub total: Uint128 }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PendingAmountResponse { pub pending: Uint128 }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VestedAmountResponse { pub vested: Uint128 }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VestingInfoResponse {
pub token: String, pub beneficiary: String, pub cliff_duration: u64,
pub total_duration: u64, pub start_time: u64, pub released: Uint128,
pub total: Uint128, pub revocable: bool, pub revoked: bool, pub admin: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct BeneficiaryResponse { pub beneficiary: String }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct CliffDurationResponse { pub cliff_duration: u64 }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct TotalDurationResponse { pub total_duration: u64 }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ElapsedTimeResponse { pub elapsed: u64, pub elapsed_percentage: u64 }
// ── 入口 ──────────────────────────────────────────────────────────────
#[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 token = addr_validate(deps.api, &msg.token)?;
let beneficiary = addr_validate(deps.api, &msg.beneficiary)?;
if msg.cliff_duration > msg.total_duration {
return Err(StdError::generic_err("cliff_duration must be <= total_duration"));
}
if msg.total_duration == 0 {
return Err(StdError::generic_err("total_duration must be > 0"));
}
TOKEN.save(deps.storage, &token)?;
BENEFICIARY.save(deps.storage, &beneficiary)?;
CLIFF_DURATION.save(deps.storage, &msg.cliff_duration)?;
TOTAL_DURATION.save(deps.storage, &msg.total_duration)?;
let start = msg.start_time.unwrap_or_else(|| _env.block.time.seconds());
START_TIME.save(deps.storage, &Timestamp::from_seconds(start))?;
RELEASED_AMOUNT.save(deps.storage, &Uint128::zero())?;
TOTAL_AMOUNT.save(deps.storage, &Uint128::zero())?;
REVOKED.save(deps.storage, &false)?;
REVOCABLE.save(deps.storage, &msg.revocable)?;
if msg.revocable {
match msg.admin {
Some(a) => ADMIN.save(deps.storage, &Some(addr_validate(deps.api, &a)?))?,
None => return Err(StdError::generic_err("revocable vesting requires an admin")),
}
} else {
ADMIN.save(deps.storage, &None)?;
}
Ok(Response::new()
.add_attribute("method", "instantiate")
.add_attribute("token", msg.token)
.add_attribute("beneficiary", msg.beneficiary))
}
#[entry_point]
pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> StdResult<Response> {
match msg {
ExecuteMsg::Release {} => execute_release(deps, env, info),
ExecuteMsg::Revoke {} => execute_revoke(deps, env, info),
ExecuteMsg::Fund {} => execute_fund(deps, env, info),
}
}
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::ReleasedAmount {} => to_binary(&query_released_amount(deps)?),
QueryMsg::TotalAmount {} => to_binary(&query_total_amount(deps)?),
QueryMsg::PendingAmount {} => to_binary(&query_pending_amount(deps, _env)?),
QueryMsg::VestedAmount {} => to_binary(&query_vested_amount(deps, _env)?),
QueryMsg::VestingInfo {} => to_binary(&query_vesting_info(deps, _env)?),
QueryMsg::Beneficiary {} => to_binary(&query_beneficiary(deps)?),
QueryMsg::CliffDuration {} => to_binary(&query_cliff_duration(deps)?),
QueryMsg::TotalDuration {} => to_binary(&query_total_duration(deps)?),
QueryMsg::ElapsedTime {} => to_binary(&query_elapsed_time(deps, _env)?),
}
}
// ── 核心计算 ──────────────────────────────────────────────────────────
pub fn compute_vested_amount(
total: Uint128, start: Timestamp, cliff: u64, total_dur: u64, now: Timestamp, revoked: bool,
) -> Uint128 {
if revoked || now < start { return Uint128::zero(); }
let elapsed = now.seconds() - start.seconds();
if elapsed < cliff { return Uint128::zero(); }
if elapsed >= total_dur { return total; }
total.multiply_ratio(elapsed, total_dur)
}
pub fn compute_releasable(total: Uint128, released: Uint128, vested: Uint128) -> Uint128 {
if vested <= released { Uint128::zero() } else { vested - released }
}
// ── 释放 ──────────────────────────────────────────────────────────────
fn execute_release(deps: DepsMut, env: Env, info: MessageInfo) -> StdResult<Response> {
let beneficiary = BENEFICIARY.load(deps.storage)?;
if info.sender != beneficiary {
return Err(StdError::generic_err("Unauthorized: only beneficiary can release"));
}
if REVOKED.load(deps.storage)? {
return Err(StdError::generic_err("Vesting has been revoked"));
}
let total = TOTAL_AMOUNT.load(deps.storage)?;
if total.is_zero() { return Err(StdError::generic_err("No tokens in vesting contract")); }
let start = START_TIME.load(deps.storage)?;
let cliff = CLIFF_DURATION.load(deps.storage)?;
let total_dur = TOTAL_DURATION.load(deps.storage)?;
let released = RELEASED_AMOUNT.load(deps.storage)?;
let token = TOKEN.load(deps.storage)?;
let vested = compute_vested_amount(total, start, cliff, total_dur, env.block.time, false);
let releasable = compute_releasable(total, released, vested);
if releasable.is_zero() { return Err(StdError::generic_err("No tokens available to release")); }
RELEASED_AMOUNT.save(deps.storage, &(released + releasable))?;
let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: token.to_string(),
msg: to_binary(&Cw20ExecuteMsg::Transfer { recipient: beneficiary.to_string(), amount: releasable })?,
funds: vec![],
});
Ok(Response::new()
.add_message(transfer_msg)
.add_attribute("method", "release")
.add_attribute("beneficiary", beneficiary.to_string())
.add_attribute("amount", releasable.to_string())
.add_attribute("total_released", (released + releasable).to_string()))
}
// ── 撤销 ──────────────────────────────────────────────────────────────
fn execute_revoke(deps: DepsMut, env: Env, info: MessageInfo) -> StdResult<Response> {
if !REVOCABLE.load(deps.storage)? { return Err(StdError::generic_err("Vesting is not revocable")); }
let admin = ADMIN.load(deps.storage)?;
match admin {
Some(ref a) if info.sender == *a => {},
_ => return Err(StdError::generic_err("Unauthorized: not admin")),
}
if REVOKED.load(deps.storage)? { return Err(StdError::generic_err("Vesting already revoked")); }
let token = TOKEN.load(deps.storage)?;
let total = TOTAL_AMOUNT.load(deps.storage)?;
let released = RELEASED_AMOUNT.load(deps.storage)?;
let start = START_TIME.load(deps.storage)?;
let cliff = CLIFF_DURATION.load(deps.storage)?;
let total_dur = TOTAL_DURATION.load(deps.storage)?;
REVOKED.save(deps.storage, &true)?;
let vested = compute_vested_amount(total, start, cliff, total_dur, env.block.time, false);
let unreleased = if total > vested { total - vested } else { Uint128::zero() };
if !unreleased.is_zero() {
let admin_addr = admin.unwrap();
let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: token.to_string(),
msg: to_binary(&Cw20ExecuteMsg::Transfer { recipient: admin_addr.to_string(), amount: unreleased })?,
funds: vec![],
});
return Ok(Response::new()
.add_message(transfer_msg)
.add_attribute("method", "revoke")
.add_attribute("unreleased_returned", unreleased.to_string()));
}
Ok(Response::new().add_attribute("method", "revoke").add_attribute("unreleased_returned", "0"))
}
// ── 存入 ──────────────────────────────────────────────────────────────
fn execute_fund(deps: DepsMut, _env: Env, info: MessageInfo) -> StdResult<Response> {
let admin = ADMIN.load(deps.storage)?;
let is_admin = admin.as_ref().map_or(false, |a| info.sender == *a);
let is_self = info.sender == _env.contract.address;
if !is_admin && !is_self { return Err(StdError::generic_err("Unauthorized: only admin or contract can fund")); }
let token = TOKEN.load(deps.storage)?;
let balance: BalanceResponse = deps.querier.query_wasm_smart(
token.clone(), &cw20::Cw20QueryMsg::Balance { address: _env.contract.address.to_string() },
)?;
let prev = TOTAL_AMOUNT.load(deps.storage)?;
TOTAL_AMOUNT.save(deps.storage, &balance.balance)?;
Ok(Response::new()
.add_attribute("method", "fund")
.add_attribute("previous_total", prev.to_string())
.add_attribute("new_total", balance.balance.to_string()))
}
// ── 查询 ──────────────────────────────────────────────────────────────
fn query_released_amount(deps: Deps) -> StdResult<ReleasedAmountResponse> {
RELEASED_AMOUNT.load(deps.storage).map(|r| ReleasedAmountResponse { released: r })
}
fn query_total_amount(deps: Deps) -> StdResult<TotalAmountResponse> {
TOTAL_AMOUNT.load(deps.storage).map(|t| TotalAmountResponse { total: t })
}
fn query_vested_amount(deps: Deps, env: Env) -> StdResult<VestedAmountResponse> {
let total = TOTAL_AMOUNT.load(deps.storage)?;
let start = START_TIME.load(deps.storage)?;
let cliff = CLIFF_DURATION.load(deps.storage)?;
let total_dur = TOTAL_DURATION.load(deps.storage)?;
let revoked = REVOKED.load(deps.storage)?;
let vested = compute_vested_amount(total, start, cliff, total_dur, env.block.time, revoked);
Ok(VestedAmountResponse { vested })
}
fn query_pending_amount(deps: Deps, env: Env) -> StdResult<PendingAmountResponse> {
let total = TOTAL_AMOUNT.load(deps.storage)?;
let start = START_TIME.load(deps.storage)?;
let cliff = CLIFF_DURATION.load(deps.storage)?;
let total_dur = TOTAL_DURATION.load(deps.storage)?;
let revoked = REVOKED.load(deps.storage)?;
let released = RELEASED_AMOUNT.load(deps.storage)?;
let vested = compute_vested_amount(total, start, cliff, total_dur, env.block.time, revoked);
let pending = compute_releasable(total, released, vested);
Ok(PendingAmountResponse { pending })
}
fn query_vesting_info(deps: Deps, env: Env) -> StdResult<VestingInfoResponse> {
let token = TOKEN.load(deps.storage)?;
let beneficiary = BENEFICIARY.load(deps.storage)?;
let cliff = CLIFF_DURATION.load(deps.storage)?;
let total_dur = TOTAL_DURATION.load(deps.storage)?;
let start = START_TIME.load(deps.storage)?;
let released = RELEASED_AMOUNT.load(deps.storage)?;
let total = TOTAL_AMOUNT.load(deps.storage)?;
let revocable = REVOCABLE.load(deps.storage)?;
let revoked = REVOKED.load(deps.storage)?;
let admin = ADMIN.load(deps.storage)?;
Ok(VestingInfoResponse {
token: token.to_string(), beneficiary: beneficiary.to_string(),
cliff_duration: cliff, total_duration: total_dur,
start_time: start.seconds(), released, total,
revocable, revoked, admin: admin.map(|a| a.to_string()),
})
}
fn query_beneficiary(deps: Deps) -> StdResult<BeneficiaryResponse> {
BENEFICIARY.load(deps.storage).map(|b| BeneficiaryResponse { beneficiary: b.to_string() })
}
fn query_cliff_duration(deps: Deps) -> StdResult<CliffDurationResponse> {
CLIFF_DURATION.load(deps.storage).map(|c| CliffDurationResponse { cliff_duration: c })
}
fn query_total_duration(deps: Deps) -> StdResult<TotalDurationResponse> {
TOTAL_DURATION.load(deps.storage).map(|t| TotalDurationResponse { total_duration: t })
}
fn query_elapsed_time(deps: Deps, env: Env) -> StdResult<ElapsedTimeResponse> {
let start = START_TIME.load(deps.storage)?;
let total_dur = TOTAL_DURATION.load(deps.storage)?;
let elapsed = if env.block.time < start { 0 } else { env.block.time.seconds() - start.seconds() };
let pct = if total_dur > 0 { ((elapsed as u128 * 100 / total_dur as u128) as u64).min(100) } else { 0 };
Ok(ElapsedTimeResponse { elapsed, elapsed_percentage: pct })
}
3.4 测试代码
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
fn setup(deps: DepsMut) {
instantiate(deps, mock_env(), mock_info("deployer", &[]), InstantiateMsg {
token: "cw20_token".to_string(), beneficiary: "beneficiary".to_string(),
cliff_duration: 365 * 86400, total_duration: 3 * 365 * 86400,
start_time: None, revocable: false, admin: None,
}).unwrap();
}
fn setup_revocable(deps: DepsMut) {
instantiate(deps, mock_env(), mock_info("deployer", &[]), InstantiateMsg {
token: "cw20_token".to_string(), beneficiary: "beneficiary".to_string(),
cliff_duration: 365 * 86400, total_duration: 3 * 365 * 86400,
start_time: None, revocable: true, admin: Some("admin".to_string()),
}).unwrap();
}
#[test]
fn test_vesting_calculation_before_cliff() {
let total = Uint128::new(1_000_000);
let start = Timestamp::from_seconds(1000);
assert_eq!(compute_vested_amount(total, start, 500, 2000, Timestamp::from_seconds(1200), false), Uint128::zero());
}
#[test]
fn test_vesting_calculation_during_linear() {
let total = Uint128::new(1_000_000);
let start = Timestamp::from_seconds(1000);
assert_eq!(compute_vested_amount(total, start, 500, 2000, Timestamp::from_seconds(2000), false), Uint128::new(500_000));
}
#[test]
fn test_vesting_calculation_after_completion() {
let total = Uint128::new(1_000_000);
let start = Timestamp::from_seconds(1000);
assert_eq!(compute_vested_amount(total, start, 500, 2000, Timestamp::from_seconds(5000), false), total);
}
#[test]
fn test_vesting_calculation_revoked() {
assert_eq!(compute_vested_amount(Uint128::new(1_000_000), Timestamp::from_seconds(1000), 500, 2000, Timestamp::from_seconds(3000), true), Uint128::zero());
}
#[test]
fn test_release_before_cliff_fails() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
TOTAL_AMOUNT.save(deps.as_mut().storage, &Uint128::new(1_000_000)).unwrap();
let err = execute(deps.as_mut(), mock_env(), mock_info("beneficiary", &[]), ExecuteMsg::Release {}).unwrap_err();
assert!(err.to_string().contains("No tokens available"));
}
#[test]
fn test_release_after_cliff() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
TOTAL_AMOUNT.save(deps.as_mut().storage, &Uint128::new(1_000_000)).unwrap();
let mut env = mock_env();
env.block.time = env.block.time.plus_seconds(400 * 86400);
let res = execute(deps.as_mut(), env.clone(), mock_info("beneficiary", &[]), ExecuteMsg::Release {}).unwrap();
assert_eq!(res.attributes[0].value, "release");
let released = RELEASED_AMOUNT.load(deps.as_ref().storage).unwrap();
assert!(!released.is_zero());
}
#[test]
fn test_full_vesting() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
TOTAL_AMOUNT.save(deps.as_mut().storage, &Uint128::new(1_000_000)).unwrap();
let mut env = mock_env();
env.block.time = env.block.time.plus_seconds(4 * 365 * 86400);
execute(deps.as_mut(), env, mock_info("beneficiary", &[]), ExecuteMsg::Release {}).unwrap();
assert_eq!(RELEASED_AMOUNT.load(deps.as_ref().storage).unwrap(), Uint128::new(1_000_000));
}
#[test]
fn test_unauthorized_release() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
TOTAL_AMOUNT.save(deps.as_mut().storage, &Uint128::new(1_000_000)).unwrap();
let mut env = mock_env();
env.block.time = env.block.time.plus_seconds(400 * 86400);
let err = execute(deps.as_mut(), env, mock_info("random_user", &[]), ExecuteMsg::Release {}).unwrap_err();
assert!(err.to_string().contains("Unauthorized"));
}
#[test]
fn test_revoke() {
let mut deps = mock_dependencies();
setup_revocable(deps.as_mut());
TOTAL_AMOUNT.save(deps.as_mut().storage, &Uint128::new(1_000_000)).unwrap();
let mut env = mock_env();
env.block.time = env.block.time.plus_seconds(500 * 86400);
execute(deps.as_mut(), env.clone(), mock_info("admin", &[]), ExecuteMsg::Revoke {}).unwrap();
assert!(REVOKED.load(deps.as_ref().storage).unwrap());
let err = execute(deps.as_mut(), env, mock_info("beneficiary", &[]), ExecuteMsg::Release {}).unwrap_err();
assert!(err.to_string().contains("has been revoked"));
}
#[test]
fn test_revoke_non_revocable_fails() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
let err = execute(deps.as_mut(), mock_env(), mock_info("anyone", &[]), ExecuteMsg::Revoke {}).unwrap_err();
assert!(err.to_string().contains("not revocable"));
}
#[test]
fn test_cannot_revoke_twice() {
let mut deps = mock_dependencies();
setup_revocable(deps.as_mut());
execute(deps.as_mut(), mock_env(), mock_info("admin", &[]), ExecuteMsg::Revoke {}).unwrap();
let err = execute(deps.as_mut(), mock_env(), mock_info("admin", &[]), ExecuteMsg::Revoke {}).unwrap_err();
assert!(err.to_string().contains("already revoked"));
}
#[test]
fn test_elapsed_time() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
let elapsed = query_elapsed_time(deps.as_ref(), mock_env()).unwrap();
assert_eq!(elapsed.elapsed, 0);
}
}
3.5 悬崖期与线性释放算法详解
compute_vested_amount(total, start, cliff, total_dur, current_time, revoked):
│
├─ revoked? → return 0
├─ current < start? → return 0
├─ elapsed = current - start
├─ elapsed < cliff? → return 0 ← 悬崖期内
├─ elapsed >= total_dur? → return total ← 全部释放
└─ else → return total * elapsed / total_dur ← 线性插值
精度说明:使用 Uint128 的 multiply_ratio 方法保证在 MSG Chain 上的精确计算,避免浮点误差。
3.6 部署示例
# 1. 部署 CW20 代币
msgd tx wasm store cw20_base.wasm --from deployer --gas auto --gas-prices 1000000000attoMSG -y
# 2. 实例化代币
msgd tx wasm instantiate 1 \
'{"name":"MSG Token","symbol":"MSG","decimals":18,"initial_balances":[],"mint":{"minter":"msg1admin..."}}' \
--label "msg-token" --from deployer --gas auto --gas-prices 1000000000attoMSG -y
# 3. 部署 TokenVesting
msgd tx wasm store token_vesting.wasm --from deployer --gas auto --gas-prices 1000000000attoMSG -y
# 4. 实例化团队 Vesting(1年 cliff,3年线性释放)
TOKEN_ADDR="msg1token..."
BENEFICIARY="msg1team..."
msgd tx wasm instantiate 2 \
'{
"token":"'$TOKEN_ADDR'","beneficiary":"'$BENEFICIARY'",
"cliff_duration":31536000,"total_duration":94608000,
"revocable":true,"admin":"msg1admin..."
}' \
--label "team-vesting" --from deployer --gas auto --gas-prices 1000000000attoMSG -y
# 5. 转入代币到 Vesting 合约
VESTING_ADDR="msg1vesting..."
msgd tx wasm execute $TOKEN_ADDR \
'{"transfer":{"recipient":"'$VESTING_ADDR'","amount":"1000000000000000000000000"}}' \
--from deployer --gas auto --gas-prices 1000000000attoMSG -y
# 6. 调用 fund 更新总量
msgd tx wasm execute $VESTING_ADDR \
'{"fund":{}}' --from admin --gas auto --gas-prices 1000000000attoMSG -y
4. VestingSchedule 工厂
4.1 设计原理
工厂模式用于批量创建和管理多个 Vesting 计划。核心优势:
- 标准化:所有 Vesting 合约使用统一模板
- 可追踪:中央注册表记录所有创建的 Vesting
- 批量操作:批量释放、批量查询
4.2 完整 Rust 代码
// src/contracts/vesting_factory.rs
use cosmwasm_std::{
addr_validate, entry_point, to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut,
Env, MessageInfo, Order, Response, StdError, StdResult, Uint128, WasmMsg,
};
use cw2::set_contract_version;
use cw_storage_plus::{Item, Map};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
const CONTRACT_NAME: &str = "msgchain-vesting-factory";
const CONTRACT_VERSION: &str = "1.0.0";
const VESTING_CODE_ID: Item<u64> = Item::new("vesting_code_id");
const FACTORY_ADMIN: Item<Addr> = Item::new("factory_admin");
const VESTING_COUNT: Item<u64> = Item::new("vesting_count");
const TOKEN: Item<Addr> = Item::new("token");
const VESTING_LIST: Map<u64, VestingRecord> = Map::new("vesting_list");
const BENEFICIARY_VESTINGS: Map<&Addr, Vec<u64>> = Map::new("beneficiary_vestings");
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VestingRecord {
pub vesting_address: Addr, pub beneficiary: Addr, pub total_amount: Uint128,
pub cliff_duration: u64, pub total_duration: u64, pub start_time: u64,
pub revocable: bool, pub created_at: u64,
}
impl VestingRecord {
pub fn progress_percentage(&self, current_time: u64) -> u64 {
let elapsed = if current_time > self.start_time { current_time - self.start_time } else { 0 };
if elapsed >= self.total_duration { return 100; }
if elapsed <= self.cliff_duration { return 0; }
((elapsed as u128 * 100) / self.total_duration as u128) as u64
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg { pub vesting_code_id: u64, pub admin: String, pub token: String }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
CreateVesting { beneficiary: String, cliff_duration: u64, total_duration: u64, amount: Uint128, revocable: bool },
BatchRelease { limit: Option<u32> },
BatchReleaseBeneficiary { beneficiary: String },
UpdateCodeId { new_code_id: u64 },
TransferAdmin { new_admin: String },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
AllVestings { start_after: Option<u64>, limit: Option<u32> },
Vesting { index: u64 },
BeneficiaryVestings { beneficiary: String },
VestingCount {},
FactoryConfig {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct AllVestingsResponse { pub vestings: Vec<VestingRecord> }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct BeneficiaryVestingsResponse { pub vestings: Vec<VestingRecord> }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct VestingCountResponse { pub count: u64 }
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct FactoryConfigResponse { pub vesting_code_id: u64, pub admin: String, pub token: String, pub vesting_count: u64 }
// ── 入口 ──────────────────────────────────────────────────────────────
#[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 admin = addr_validate(deps.api, &msg.admin)?;
let token = addr_validate(deps.api, &msg.token)?;
if msg.vesting_code_id == 0 { return Err(StdError::generic_err("vesting_code_id must be > 0")); }
VESTING_CODE_ID.save(deps.storage, &msg.vesting_code_id)?;
FACTORY_ADMIN.save(deps.storage, &admin)?;
TOKEN.save(deps.storage, &token)?;
VESTING_COUNT.save(deps.storage, &0u64)?;
Ok(Response::new().add_attribute("method", "instantiate"))
}
#[entry_point]
pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> StdResult<Response> {
match msg {
ExecuteMsg::CreateVesting { beneficiary, cliff_duration, total_duration, amount, revocable } =>
execute_create_vesting(deps, env, info, beneficiary, cliff_duration, total_duration, amount, revocable),
ExecuteMsg::BatchRelease { limit } => execute_batch_release(deps, env, info, limit),
ExecuteMsg::BatchReleaseBeneficiary { beneficiary } => execute_batch_release_beneficiary(deps, env, info, beneficiary),
ExecuteMsg::UpdateCodeId { new_code_id } => execute_update_code_id(deps, env, info, new_code_id),
ExecuteMsg::TransferAdmin { new_admin } => execute_transfer_admin(deps, env, info, new_admin),
}
}
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::AllVestings { start_after, limit } => to_binary(&query_all_vestings(deps, start_after, limit)?),
QueryMsg::Vesting { index } => to_binary(&query_vesting(deps, index)?),
QueryMsg::BeneficiaryVestings { beneficiary } => to_binary(&query_beneficiary_vestings(deps, beneficiary)?),
QueryMsg::VestingCount {} => to_binary(&query_vesting_count(deps)?),
QueryMsg::FactoryConfig {} => to_binary(&query_factory_config(deps)?),
}
}
// ── 创建 Vesting ──────────────────────────────────────────────────────
fn execute_create_vesting(
deps: DepsMut, env: Env, info: MessageInfo,
beneficiary: String, cliff_duration: u64, total_duration: u64,
amount: Uint128, revocable: bool,
) -> StdResult<Response> {
let admin = FACTORY_ADMIN.load(deps.storage)?;
if info.sender != admin { return Err(StdError::generic_err("Unauthorized: not admin")); }
let beneficiary_addr = addr_validate(deps.api, &beneficiary)?;
let token = TOKEN.load(deps.storage)?;
let code_id = VESTING_CODE_ID.load(deps.storage)?;
if amount.is_zero() { return Err(StdError::generic_err("Amount must be > 0")); }
if total_duration == 0 { return Err(StdError::generic_err("total_duration must be > 0")); }
if cliff_duration > total_duration { return Err(StdError::generic_err("cliff must be <= total")); }
let label = format!("vesting-{}-{}", beneficiary_addr.as_str().chars().take(8).collect::<String>(), env.block.time.seconds());
let count = VESTING_COUNT.load(deps.storage)?;
let new_count = count + 1;
VESTING_COUNT.save(deps.storage, &new_count)?;
let record = VestingRecord {
vesting_address: Addr::unchecked("pending"), beneficiary: beneficiary_addr.clone(),
total_amount: amount, cliff_duration, total_duration,
start_time: env.block.time.seconds(), revocable, created_at: env.block.time.seconds(),
};
VESTING_LIST.save(deps.storage, new_count, &record)?;
let mut bv = BENEFICIARY_VESTINGS.may_load(deps.storage, &beneficiary_addr)?.unwrap_or_default();
bv.push(new_count);
BENEFICIARY_VESTINGS.save(deps.storage, &beneficiary_addr, &bv)?;
Ok(Response::new()
.add_attribute("method", "create_vesting")
.add_attribute("vesting_index", new_count.to_string())
.add_attribute("beneficiary", &beneficiary))
}
// ── 批量释放 ──────────────────────────────────────────────────────────
fn execute_batch_release(deps: DepsMut, env: Env, info: MessageInfo, limit: Option<u32>) -> StdResult<Response> {
let count = VESTING_COUNT.load(deps.storage)?;
let max = limit.unwrap_or(10) as u64;
let mut released_count = 0u64;
let mut messages: Vec<CosmosMsg> = vec![];
for i in 1..=count {
if released_count >= max { break; }
if let Ok(record) = VESTING_LIST.load(deps.storage, i) {
if record.vesting_address == Addr::unchecked("pending") { continue; }
messages.push(CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: record.vesting_address.to_string(),
msg: to_binary(&token_vesting::ExecuteMsg::Release {})?,
funds: vec![],
}));
released_count += 1;
}
}
Ok(Response::new().add_messages(messages).add_attribute("method", "batch_release").add_attribute("count", released_count.to_string()))
}
fn execute_batch_release_beneficiary(deps: DepsMut, env: Env, info: MessageInfo, beneficiary: String) -> StdResult<Response> {
let beneficiary_addr = addr_validate(deps.api, &beneficiary)?;
let indices = BENEFICIARY_VESTINGS.may_load(deps.storage, &beneficiary_addr)?.unwrap_or_default();
let mut messages: Vec<CosmosMsg> = vec![];
for &idx in &indices {
if let Ok(record) = VESTING_LIST.load(deps.storage, idx) {
if record.vesting_address == Addr::unchecked("pending") { continue; }
messages.push(CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: record.vesting_address.to_string(),
msg: to_binary(&token_vesting::ExecuteMsg::Release {})?,
funds: vec![],
}));
}
}
Ok(Response::new().add_messages(messages).add_attribute("method", "batch_release_beneficiary").add_attribute("count", indices.len().to_string()))
}
// ── 管理 ──────────────────────────────────────────────────────────────
fn execute_update_code_id(deps: DepsMut, _env: Env, info: MessageInfo, new_code_id: u64) -> StdResult<Response> {
let admin = FACTORY_ADMIN.load(deps.storage)?;
if info.sender != admin { return Err(StdError::generic_err("Unauthorized")); }
VESTING_CODE_ID.save(deps.storage, &new_code_id)?;
Ok(Response::new().add_attribute("method", "update_code_id").add_attribute("new_code_id", new_code_id.to_string()))
}
fn execute_transfer_admin(deps: DepsMut, _env: Env, info: MessageInfo, new_admin: String) -> StdResult<Response> {
let admin = FACTORY_ADMIN.load(deps.storage)?;
if info.sender != admin { return Err(StdError::generic_err("Unauthorized")); }
FACTORY_ADMIN.save(deps.storage, &addr_validate(deps.api, &new_admin)?)?;
Ok(Response::new().add_attribute("method", "transfer_admin").add_attribute("new_admin", &new_admin))
}
// ── 查询 ──────────────────────────────────────────────────────────────
fn query_all_vestings(deps: Deps, start_after: Option<u64>, limit: Option<u32>) -> StdResult<AllVestingsResponse> {
let start = start_after.unwrap_or(0);
let limit = limit.unwrap_or(20) as usize;
let vestings: Vec<VestingRecord> = VESTING_LIST.range(deps.storage, None, None, Order::Ascending)
.filter_map(|item| item.ok()).filter(|(k, _)| *k > start).take(limit).map(|(_, v)| v).collect();
Ok(AllVestingsResponse { vestings })
}
fn query_vesting(deps: Deps, index: u64) -> StdResult<VestingRecord> { VESTING_LIST.load(deps.storage, index) }
fn query_beneficiary_vestings(deps: Deps, beneficiary: String) -> StdResult<BeneficiaryVestingsResponse> {
let addr = addr_validate(deps.api, &beneficiary)?;
let indices = BENEFICIARY_VESTINGS.may_load(deps.storage, &addr)?.unwrap_or_default();
let mut vestings = vec![];
for &idx in &indices { if let Ok(r) = VESTING_LIST.load(deps.storage, idx) { vestings.push(r); } }
Ok(BeneficiaryVestingsResponse { vestings })
}
fn query_vesting_count(deps: Deps) -> StdResult<VestingCountResponse> {
VESTING_COUNT.load(deps.storage).map(|c| VestingCountResponse { count: c })
}
fn query_factory_config(deps: Deps) -> StdResult<FactoryConfigResponse> {
Ok(FactoryConfigResponse {
vesting_code_id: VESTING_CODE_ID.load(deps.storage)?,
admin: FACTORY_ADMIN.load(deps.storage)?.to_string(),
token: TOKEN.load(deps.storage)?.to_string(),
vesting_count: VESTING_COUNT.load(deps.storage)?,
})
}
4.3 工厂测试
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
fn setup(deps: DepsMut) {
instantiate(deps, mock_env(), mock_info("deployer", &[]), InstantiateMsg {
vesting_code_id: 42, admin: "admin".to_string(), token: "cw20_token".to_string(),
}).unwrap();
}
#[test]
fn test_create_vesting() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
execute(deps.as_mut(), mock_env(), mock_info("admin", &[]), ExecuteMsg::CreateVesting {
beneficiary: "b1".to_string(), cliff_duration: 365 * 86400,
total_duration: 3 * 365 * 86400, amount: Uint128::new(1_000_000), revocable: true,
}).unwrap();
assert_eq!(query_vesting_count(deps.as_ref()).unwrap().count, 1);
}
#[test]
fn test_multiple_vestings() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
let env = mock_env();
let info = mock_info("admin", &[]);
for i in 1..=5 {
execute(deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::CreateVesting {
beneficiary: format!("b{}", i), cliff_duration: 100, total_duration: 1000,
amount: Uint128::new(1_000_000), revocable: i % 2 == 0,
}).unwrap();
}
assert_eq!(query_vesting_count(deps.as_ref()).unwrap().count, 5);
}
#[test]
fn test_beneficiary_index() {
let mut deps = mock_dependencies();
setup(deps.as_mut());
let env = mock_env();
let info = mock_info("admin", &[]);
for _ in 0..3 {
execute(deps.as_mut(), env.clone(), info.clone(), ExecuteMsg::CreateVesting {
beneficiary: "same".to_string(), cliff_duration: 100, total_duration: 1000,
amount: Uint128::new(1_000_000), revocable: false,
}).unwrap();
}
assert_eq!(query_beneficiary_vestings(deps.as_ref(), "same".to_string()).unwrap().vestings.len(), 3);
}
#[test]
fn test_progress_percentage() {
let record = VestingRecord {
vesting_address: Addr::unchecked("v1"), beneficiary: Addr::unchecked("b1"),
total_amount: Uint128::new(1_000_000), cliff_duration: 100, total_duration: 500,
start_time: 1000, revocable: false, created_at: 1000,
};
assert_eq!(record.progress_percentage(500), 0);
assert_eq!(record.progress_percentage(1050), 0);
assert_eq!(record.progress_percentage(1250), 50);
assert_eq!(record.progress_percentage(2000), 100);
}
}
4.4 工厂使用流程
1. 部署 TokenVesting 合约(作为模板)
2. 部署 VestingFactory(指定模板 Code ID)
3. 调用 create_vesting 创建 Vesting 计划
4. 工厂自动实例化新 TokenVesting 合约
5. 向每个 Vesting 合约转入代币
6. 受益人调用 release 或工厂调用 batch_release
4.5 批量释放 CLI
# 工厂批量释放前10个可释放的 Vesting
msgd tx wasm execute FACTORY_ADDR \
'{"batch_release":{"limit":10}}' \
--from executor --gas auto --gas-prices 1000000000attoMSG -y
# 为特定受益人批量释放
msgd tx wasm execute FACTORY_ADDR \
'{"batch_release_beneficiary":{"beneficiary":"msg1beneficiary..."}}' \
--from executor --gas auto --gas-prices 1000000000attoMSG -y
5. CLI 操作
5.1 Python CLI
#!/usr/bin/env python3
\"\"\"MSG Chain Vesting CLI\"\"\"
import json, sys, time, os
from dataclasses import dataclass
from typing import Optional, List
from msg_chain_devkit import MsgChainClient, Address, Coin, Wallet, Transaction, Contract
from msg_chain_devkit.cosmwasm import CW20Contract
RPC = "https://rpc.msgchain.org:443"
CHAIN_ID = "msg-chain-1"
GAS_PRICE = "1000000000attoMSG"
GAS_ADJ = 1.3
@dataclass
class VestingConfig:
beneficiary: str; cliff_duration: int; total_duration: int
amount: int; revocable: bool = False; start_time: Optional[int] = None
@dataclass
class VestingStatus:
beneficiary: str; total_amount: int; released_amount: int; vested_amount: int
pending_amount: int; cliff_duration: int; total_duration: int; start_time: int
elapsed_seconds: int; progress_percent: float; revocable: bool; revoked: bool
class VestingClient:
def __init__(self, mnemonic: str):
self.client = MsgChainClient(rpc_endpoint=RPC, chain_id=CHAIN_ID, mnemonic=mnemonic)
self.wallet = self.client.wallet
def create_vesting(self, code_id: int, token: str, config: VestingConfig, label: str = "vesting") -> str:
msg = {
"token": token, "beneficiary": config.beneficiary,
"cliff_duration": config.cliff_duration, "total_duration": config.total_duration,
"start_time": config.start_time, "revocable": config.revocable,
"admin": self.wallet.address if config.revocable else None,
}
tx = Transaction(msgs=[self.client.wasm().instantiate(code_id=code_id, msg=msg, funds=[], label=label, admin=self.wallet.address)], gas_price=GAS_PRICE, gas_adjustment=GAS_ADJ)
result = self.client.broadcast_tx(tx)
addr = self._extract_addr(result)
print(f"✓ Vesting: {addr}")
return addr
def fund(self, token: str, vesting: str, amount: int):
CW20Contract(self.client, token).transfer(recipient=vesting, amount=amount, gas_price=GAS_PRICE)
Contract(self.client, vesting).execute({"fund": {}})
print(f"✓ 已存入 {amount} 到 {vesting}")
def release(self, vesting: str) -> dict:
try:
result = self.client.broadcast_tx(Transaction(msgs=[Contract(self.client, vesting).execute({"release": {}})], gas_price=GAS_PRICE))
for log in result.get("logs", []):
for attr in log.get("events", {}).get("wasm", []):
if attr.get("key") == "amount": return {"success": True, "amount": int(attr["value"])}
return {"success": True, "amount": 0}
except Exception as e:
print(f"✗ 释放失败: {e}")
return {"success": False, "error": str(e)}
def status(self, vesting: str) -> VestingStatus:
info = Contract(self.client, vesting).query({"vesting_info": {}})
total = int(info["total"]); released = int(info["released"])
start = info["start_time"]; cliff = info["cliff_duration"]; td = info["total_duration"]
now = int(time.time()); elapsed = max(0, now - start)
if info.get("revoked", False): vested = 0
elif elapsed < cliff: vested = 0
elif elapsed >= td: vested = total
else: vested = total * elapsed // td
pending = max(0, vested - released)
pct = min(100.0, (elapsed / td * 100) if td > 0 else 0)
return VestingStatus(beneficiary=info["beneficiary"], total_amount=total, released_amount=released,
vested_amount=vested, pending_amount=pending, cliff_duration=cliff, total_duration=td,
start_time=start, elapsed_seconds=elapsed, progress_percent=round(pct, 2),
revocable=info["revocable"], revoked=info["revoked"])
@staticmethod
def _extract_addr(result: dict) -> str:
for log in result.get("logs", []):
for event in log.get("events", []):
if event.get("type") == "instantiate":
for attr in event.get("attributes", []):
if attr.get("key") == "_contract_address": return attr["value"]
raise ValueError("Cannot extract contract address")
def print_status(s: VestingStatus):
bar = "█" * int(s.progress_percent / 100 * 40) + "░" * (40 - int(s.progress_percent / 100 * 40))
print(f"\n 受益人: {s.beneficiary}\n 总量: {s.total_amount/1e18:.4f}\n 已释放: {s.released_amount/1e18:.4f}\n 已归属: {s.vested_amount/1e18:.4f}\n 待释放: {s.pending_amount/1e18:.4f}\n 进度: {s.progress_percent:.1f}%\n [{bar}]\n 可撤销: {'是' if s.revocable else '否'}\n")
def main():
import argparse
p = argparse.ArgumentParser(description="MSG Chain Vesting CLI")
p.add_argument("--mnemonic", default=None)
sub = p.add_subparsers(dest="cmd", required=True)
sp = sub.add_parser("create")
sp.add_argument("--code-id", type=int, required=True); sp.add_argument("--token", required=True)
sp.add_argument("--beneficiary", required=True); sp.add_argument("--amount", type=int, required=True)
sp.add_argument("--cliff-days", type=int, default=0); sp.add_argument("--total-days", type=int, required=True)
sp.add_argument("--revocable", action="store_true")
sp2 = sub.add_parser("status"); sp2.add_argument("--vesting", required=True)
sp3 = sub.add_parser("release"); sp3.add_argument("--vesting", required=True)
args = p.parse_args()
mnemonic = args.mnemonic or os.environ.get("MSG_MNEMONIC")
if not mnemonic: print("需要助记词"); sys.exit(1)
cli = VestingClient(mnemonic)
if args.cmd == "create":
cli.create_vesting(args.code_id, args.token, VestingConfig(beneficiary=args.beneficiary,
cliff_duration=args.cliff_days*86400, total_duration=args.total_days*86400,
amount=args.amount, revocable=args.revocable))
elif args.cmd == "status": print_status(cli.status(args.vesting))
elif args.cmd == "release": cli.release(args.vesting)
if __name__ == "__main__":
main()
5.2 TypeScript CLI
// cli/vesting-cli.ts
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { GasPrice } from "@cosmjs/stargate";
interface VestingInfo {
token: string; beneficiary: string; cliff_duration: number;
total_duration: number; start_time: number; released: string;
total: string; revocable: boolean; revoked: boolean; admin: string | null;
}
class VestingCLI {
private client!: SigningCosmWasmClient;
private wallet!: DirectSecp256k1HdWallet;
private readonly rpc = "https://rpc.msgchain.org:443";
private readonly prefix = "msg";
constructor(mnemonic: string) { this.init(mnemonic); }
private async init(mnemonic: string) {
this.wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix: this.prefix });
this.client = await SigningCosmWasmClient.connectWithSigner(this.rpc, this.wallet, {
gasPrice: GasPrice.fromString("1000000000attoMSG"),
});
}
async createVesting(params: {
codeId: number; token: string; beneficiary: string; amount: string;
cliffDays: number; totalDays: number; revocable?: boolean; label?: string;
}) {
const [acct] = await this.wallet.getAccounts();
const result = await this.client.instantiate(acct.address, params.codeId, {
token: params.token, beneficiary: params.beneficiary,
cliff_duration: params.cliffDays * 86400, total_duration: params.totalDays * 86400,
start_time: Math.floor(Date.now() / 1000),
revocable: params.revocable ?? false, admin: params.revocable ? acct.address : null,
}, params.label ?? "vesting", "auto", { admin: acct.address });
console.log(`✓ Vesting: ${result.contractAddress}`);
return result.contractAddress;
}
async release(vesting: string) {
const [acct] = await this.wallet.getAccounts();
const result = await this.client.execute(acct.address, vesting, { release: {} }, "auto");
const amount = result.events.flatMap(e => e.attributes).find(a => a.key === "amount")?.value;
console.log(`✓ 释放 ${amount}`);
}
async status(vesting: string): Promise<VestingInfo> {
return this.client.queryContractSmart(vesting, { vesting_info: {} });
}
async printStatus(vesting: string) {
const info = await this.status(vesting);
const now = Math.floor(Date.now() / 1000);
const elapsed = Math.max(0, now - info.start_time);
const pct = elapsed >= info.total_duration ? 100 : elapsed <= info.cliff_duration ? 0 : Math.round((elapsed / info.total_duration) * 100);
const filled = Math.round(pct / 100 * 40);
console.log(`\n 受益人: ${info.beneficiary}\n 总量: ${Number(info.total)/1e18} MSG\n 已释放: ${Number(info.released)/1e18} MSG\n 进度: ${pct}%\n ${"█".repeat(filled)}${"░".repeat(40-filled)}\n`);
}
}
6. 前端集成
6.1 Vesting Dashboard
// components/VestingDashboard.tsx
import React, { useEffect, useState, useCallback } from "react";
import { useCosmWasmClient } from "../hooks/useCosmWasmClient";
import type { VestingInfo } from "../types/vesting";
interface Props { vestingAddress: string; beneficiaryAddress: string }
export function VestingDashboard({ vestingAddress, beneficiaryAddress }: Props) {
const { client, address, execute } = useCosmWasmClient();
const [info, setInfo] = useState<VestingInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const fetchInfo = useCallback(async () => {
if (!client) return;
setRefreshing(true);
try {
setInfo(await client.queryContractSmart(vestingAddress, { vesting_info: {} }));
setError(null);
} catch (err) { setError(err instanceof Error ? err.message : "查询失败"); }
finally { setLoading(false); setRefreshing(false); }
}, [client, vestingAddress]);
useEffect(() => { fetchInfo(); const i = setInterval(fetchInfo, 30000); return () => clearInterval(i); }, [fetchInfo]);
const handleRelease = useCallback(async () => {
if (!address) { setError("请连接钱包"); return; }
try {
await execute(vestingAddress, { release: {} }, "auto");
await fetchInfo();
} catch (err) { setError(err instanceof Error ? err.message : "释放失败"); }
}, [address, execute, vestingAddress, fetchInfo]);
if (loading) return <div className="p-4 text-center">加载 Vesting 信息...</div>;
if (error) return <div className="p-4 text-red-500">⚠ {error} <button onClick={fetchInfo}>重试</button></div>;
if (!info) return null;
const now = Math.floor(Date.now() / 1000);
const elapsed = Math.max(0, now - info.start_time);
const pct = Math.min(100, info.total_duration > 0 ? (elapsed / info.total_duration) * 100 : 0);
const inCliff = elapsed < info.cliff_duration;
return (
<div className="max-w-2xl mx-auto p-6">
<h2 className="text-2xl font-bold mb-4">代币锁仓 / Token Vesting</h2>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded"><div className="text-sm text-gray-500">总锁仓</div><div className="text-xl font-bold">{(Number(info.total)/1e18).toFixed(2)} MSG</div></div>
<div className="bg-green-50 p-4 rounded"><div className="text-sm text-gray-500">已释放</div><div className="text-xl font-bold">{(Number(info.released)/1e18).toFixed(2)} MSG</div></div>
<div className="bg-orange-50 p-4 rounded"><div className="text-sm text-gray-500">待释放</div><div className="text-xl font-bold">{((Number(info.total)-Number(info.released))/1e18).toFixed(2)} MSG</div></div>
</div>
<div className="mb-6">
<div className="h-4 bg-gray-200 rounded-full overflow-hidden relative">
<div className={`h-full ${inCliff ? "bg-yellow-400" : "bg-blue-500"} transition-all`} style={{width: `${Math.min(pct,100)}%`}} />
{info.cliff_duration > 0 && (
<div className="absolute top-0 w-0.5 h-full bg-red-500" style={{left: `${(info.cliff_duration/info.total_duration)*100}%`}} />
)}
</div>
<div className="flex justify-between text-xs mt-1">
<span>{new Date(info.start_time*1000).toLocaleDateString("zh-CN")}</span>
<span>{inCliff ? "悬崖期" : `${Math.round(pct)}%`}</span>
<span>{new Date((info.start_time+info.total_duration)*1000).toLocaleDateString("zh-CN")}</span>
</div>
</div>
<div className="space-y-2 mb-6">
<div className="flex justify-between"><span className="text-gray-500">受益人</span><code className="text-sm">{info.beneficiary}</code></div>
<div className="flex justify-between"><span className="text-gray-500">悬崖期</span><span>{Math.floor(info.cliff_duration/86400)} 天</span></div>
<div className="flex justify-between"><span className="text-gray-500">总时长</span><span>{Math.floor(info.total_duration/86400)} 天</span></div>
<div className="flex justify-between"><span className="text-gray-500">已过时间</span><span>{Math.floor(elapsed/86400)} 天</span></div>
<div className="flex justify-between"><span className="text-gray-500">可撤销</span><span>{info.revocable ? "是" : "否"}</span></div>
<div className="flex justify-between"><span className="text-gray-500">已撤销</span><span>{info.revoked ? "是" : "否"}</span></div>
</div>
{address === beneficiaryAddress && (
<button onClick={handleRelease} disabled={info.revoked}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400">
{info.revoked ? "已撤销" : "释放已解锁代币"}
</button>
)}
</div>
);
}
6.2 Vesting Progress 进度条
// components/VestingProgress.tsx
import React from "react";
interface Props { startTime: number; cliffDuration: number; totalDuration: number }
export function VestingProgress({ startTime, cliffDuration, totalDuration }: Props) {
const now = Math.floor(Date.now() / 1000);
const elapsed = Math.max(0, now - startTime);
const pct = totalDuration > 0 ? Math.min(100, (elapsed / totalDuration) * 100) : 0;
const cliff = totalDuration > 0 ? (cliffDuration / totalDuration) * 100 : 0;
const inCliff = elapsed < cliffDuration;
return (
<div className="my-4">
<div className="relative h-6 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full transition-all ${inCliff ? "bg-yellow-400" : "bg-gradient-to-r from-blue-400 to-blue-600"}`}
style={{ width: `${Math.min(pct, 100)}%` }} />
{cliff > 0 && <div className="absolute top-0 bottom-0 w-0.5 bg-red-500 z-10" style={{ left: `${cliff}%` }} />}
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-gray-700">
{inCliff ? `悬崖期 ${Math.round(pct)}%` : `${Math.round(pct)}%`}
</div>
</div>
<div className="flex justify-between text-xs mt-1">
<span>开始</span>
<span>悬崖 {Math.floor(cliffDuration / 86400)}天</span>
<span>完成 {Math.floor(totalDuration / 86400)}天</span>
</div>
</div>
);
}
6.3 React Hook
// hooks/useVesting.ts
import { useState, useEffect, useCallback } from "react";
import { useCosmWasmClient } from "./useCosmWasmClient";
interface VestingInfo {
token: string; beneficiary: string; cliff_duration: number;
total_duration: number; start_time: number; released: string;
total: string; revocable: boolean; revoked: boolean; admin: string | null;
}
export function useVesting(vestingAddress: string) {
const { client, address, execute } = useCosmWasmClient();
const [info, setInfo] = useState<VestingInfo | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
if (!client) return;
try {
setInfo(await client.queryContractSmart(vestingAddress, { vesting_info: {} }));
} catch (e) { /* ignore */ }
finally { setLoading(false); }
}, [client, vestingAddress]);
useEffect(() => { refresh(); const i = setInterval(refresh, 30000); return () => clearInterval(i); }, [refresh]);
const progress = (): number => {
if (!info) return 0;
const n = Math.floor(Date.now() / 1000);
const e = Math.max(0, n - info.start_time);
if (e >= info.total_duration) return 100;
if (e <= info.cliff_duration) return 0;
return (e / info.total_duration) * 100;
};
const releasable = (): string => {
if (!info) return "0";
const n = Math.floor(Date.now() / 1000);
const e = Math.max(0, n - info.start_time);
const t = BigInt(info.total), r = BigInt(info.released);
if (info.revoked || e < info.cliff_duration) return "0";
const v = e >= info.total_duration ? t : (t * BigInt(e)) / BigInt(info.total_duration);
return v > r ? (v - r).toString() : "0";
};
const release = async () => {
const result = await execute(vestingAddress, { release: {} }, "auto");
await refresh();
return result;
};
return { info, loading, progress, releasable, release, refresh };
}
6.4 TypeScript 类型
// types/vesting.ts
export interface VestingInfo {
token: string;
beneficiary: string;
cliff_duration: number;
total_duration: number;
start_time: number;
released: string;
total: string;
revocable: boolean;
revoked: boolean;
admin: string | null;
}
export function computeVested(
total: string, start: number, cliff: number, totalDur: number, now: number, revoked: boolean
): string {
if (revoked) return "0";
const e = Math.max(0, now - start);
if (e < cliff) return "0";
if (e >= totalDur) return total;
return (BigInt(total) * BigInt(e) / BigInt(totalDur)).toString();
}
export function formatMSG(amount: string, decimals: number = 18): string {
const n = Number(amount) / 10 ** decimals;
return n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 4 }) + " MSG";
}
export function vestingProgress(info: VestingInfo): number {
const n = Math.floor(Date.now() / 1000);
const e = Math.max(0, n - info.start_time);
if (e >= info.total_duration) return 100;
if (e <= info.cliff_duration) return 0;
return (e / info.total_duration) * 100;
}
7. 常见场景与代码示例
7.1 团队 Vesting:1年悬崖期 + 3年线性释放
参数:
cliff_duration = 365 * 86400 = 31,536,000 秒 (1年)
total_duration = 1460 * 86400 = 126,144,000 秒 (4年)
revocable = true (可撤销,如员工离职)
释放曲线:
第0-1年: 0%(悬崖期)
第1年末: 25% 一次性释放
第2年末: 50%
第3年末: 75%
第4年末: 100%
合约实例化:
msgd tx wasm instantiate CODE_ID \
'{
"token":"msg1token...",
"beneficiary":"msg1team_member...",
"cliff_duration":31536000,
"total_duration":126144000,
"start_time":'$(date +%s)',
"revocable":true,
"admin":"msg1admin..."
}' \
--label "team-vesting-employee1" \
--from deployer --gas auto --gas-prices 1000000000attoMSG -y
Python 创建:
from vesting_cli import VestingClient, VestingConfig
cli = VestingClient(os.environ["MSG_MNEMONIC"])
team_members = [
("msg1alice...", 500_000_000_000_000_000_000_000), # 500K MSG
("msg1bob...", 300_000_000_000_000_000_000_000), # 300K MSG
("msg1carol...", 200_000_000_000_000_000_000_000), # 200K MSG
]
for addr, amount in team_members:
cli.create_vesting(
code_id=2,
token="msg1token...",
config=VestingConfig(
beneficiary=addr,
cliff_duration=31536000,
total_duration=126144000,
amount=amount,
revocable=True,
),
label=f"team-{addr[:8]}",
)
7.2 投资者 Vesting:6个月悬崖期 + 2年线性释放
参数:
cliff_duration = 180 * 86400 = 15,552,000 秒 (6个月)
total_duration = 900 * 86400 = 77,760,000 秒 (2.5年)
revocable = false (不可撤销,保护投资者权益)
msgd tx wasm instantiate CODE_ID \
'{
"token":"msg1token...",
"beneficiary":"msg1investor...",
"cliff_duration":15552000,
"total_duration":77760000,
"revocable":false
}' \
--label "investor-vesting" \
--from deployer --gas auto --gas-prices 1000000000attoMSG -y
7.3 顾问 Vesting:3个月悬崖期 + 1年线性释放
参数:
cliff_duration = 90 * 86400 = 7,776,000 秒 (3个月)
total_duration = 455 * 86400 = 39,312,000 秒 (15个月)
revocable = true (顾问合作关系可终止)
// TypeScript 创建顾问 Vesting
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { GasPrice } from "@cosmjs/stargate";
async function createAdvisorVesting() {
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(process.env.MSG_MNEMONIC!, {prefix: "msg"});
const [acct] = await wallet.getAccounts();
const client = await SigningCosmWasmClient.connectWithSigner("https://rpc.msgchain.org:443", wallet, {
gasPrice: GasPrice.fromString("1000000000attoMSG"),
});
const advisors = [
{ addr: "msg1advisor1...", amount: "100000000000000000000000" }, // 100K
{ addr: "msg1advisor2...", amount: "50000000000000000000000" }, // 50K
];
for (const a of advisors) {
const r = await client.instantiate(acct.address, 2, {
token: "msg1token...", beneficiary: a.addr,
cliff_duration: 7776000, total_duration: 39312000,
start_time: Math.floor(Date.now() / 1000),
revocable: true, admin: acct.address,
}, `advisor-${a.addr.slice(0, 8)}`, "auto", { admin: acct.address });
console.log(`✓ 顾问 Vesting: ${r.contractAddress}`);
}
}
7.4 空投线性解锁(6个月,无悬崖期)
参数:
cliff_duration = 0 (无悬崖期)
total_duration = 180 * 86400 = 15,552,000 秒 (6个月)
revocable = false
释放曲线(线性):
第1天: ~0.55%
第30天: ~16.7%
第90天: 50%
第180天: 100%
// Rust 创建空投 Vesting(无悬崖期)
use cosmwasm_std::Uint128;
let instantiate_msg = InstantiateMsg {
token: "msg1token...".to_string(),
beneficiary: "msg1airdrop_user...".to_string(),
cliff_duration: 0, // 无悬崖期
total_duration: 15_552_000, // 6个月
start_time: Some(1_700_000_000), // 空投开始时间
revocable: false,
admin: None,
};
批量空投脚本:
import json, os, csv
from vesting_cli import VestingClient, VestingConfig
cli = VestingClient(os.environ["MSG_MNEMONIC"])
# 从 CSV 读取空投列表
with open("airdrop_list.csv") as f:
reader = csv.DictReader(f)
for row in reader:
addr = row["address"]
amount = int(float(row["amount"]) * 10**18)
cli.create_vesting(
code_id=2,
token="msg1token...",
config=VestingConfig(
beneficiary=addr,
cliff_duration=0,
total_duration=15552000, # 6个月
amount=amount,
revocable=False,
),
label=f"airdrop-{addr[:8]}",
)
print(f"✓ {addr}: {amount/1e18:.2f} MSG")
7.5 多阶段释放(自定义释放计划)
对于需要自定义释放比例的场景(例如非均匀释放),可以扩展 Vesting 合约:
// 自定义释放计划
pub struct CustomSchedule {
pub stages: Vec<ReleaseStage>,
}
pub struct ReleaseStage {
pub unlock_at: Timestamp, // 解锁时间点
pub percentage: u64, // 释放百分比(如 25%)
}
impl CustomSchedule {
pub fn compute_vested(&self, total: Uint128, now: Timestamp) -> Uint128 {
let mut vested = Uint128::zero();
for stage in &self.stages {
if now >= stage.unlock_at {
vested += total.multiply_ratio(stage.percentage, 100u64);
}
}
vested
}
}
// 示例:1年25%,2年50%,3年75%,4年100%
let schedule = CustomSchedule {
stages: vec![
ReleaseStage { unlock_at: Timestamp::from_seconds(start + 31536000), percentage: 25 },
ReleaseStage { unlock_at: Timestamp::from_seconds(start + 63072000), percentage: 25 },
ReleaseStage { unlock_at: Timestamp::from_seconds(start + 94608000), percentage: 25 },
ReleaseStage { unlock_at: Timestamp::from_seconds(start + 126144000), percentage: 25 },
],
};
7.6 基于区块的 Vesting(替代基于时间的 Vesting)
MSG Chain 出块时间约 5 秒,也可以使用区块高度来控制释放:
pub struct BlockBasedVesting {
pub token: Addr,
pub beneficiary: Addr,
pub cliff_blocks: u64, // 悬崖区块数
pub total_blocks: u64, // 总区块数
pub start_block: u64, // 起始区块
pub released_amount: Uint128,
pub total_amount: Uint128,
}
pub fn compute_vested_by_block(
total: Uint128,
start_block: u64,
cliff_blocks: u64,
total_blocks: u64,
current_block: u64,
) -> Uint128 {
if current_block < start_block { return Uint128::zero(); }
let elapsed = current_block - start_block;
if elapsed < cliff_blocks { return Uint128::zero(); }
if elapsed >= total_blocks { return total; }
total.multiply_ratio(elapsed, total_blocks)
}
// 4年 = 6,300,000 * 4 = 25,200,000 区块
// 1年悬崖 = 6,300,000 区块
8. 安全与合规
8.1 可撤销 vs 不可撤销
| 特性 | 不可撤销 Vesting | 可撤销 Vesting |
|---|---|---|
| 受益人权益 | 安全,合约不可更改 | 可能被管理员取消 |
| 管理员权限 | 无 | 可收回未释放代币 |
| 适用场景 | 空投、公开销售、投资者 | 团队、顾问、激励计划 |
| 信任假设 | 无需信任管理员 | 需要信任管理员不滥用权限 |
最佳实践:
- 投资者 Vesting 应设为不可撤销
- 团队 Vesting 建议设为可撤销(应对人员变动)
- 可撤销 Vesting 的管理员应使用多签钱包
8.2 治理覆盖
在 DAO 治理下,可以引入治理模块来覆盖 Vesting 参数:
// 治理覆盖逻辑
fn execute_governance_override(
deps: DepsMut,
env: Env,
info: MessageInfo,
new_beneficiary: Option<String>,
new_cliff: Option<u64>,
new_duration: Option<u64>,
) -> StdResult<Response> {
// 仅允许 DAO 治理合约调用
let governance = GOVERNANCE.load(deps.storage)?;
if info.sender != governance {
return Err(StdError::generic_err("Only governance can override"));
}
if let Some(b) = new_beneficiary {
BENEFICIARY.save(deps.storage, &addr_validate(deps.api, &b)?)?;
}
if let Some(c) = new_cliff {
CLIFF_DURATION.save(deps.storage, &c)?;
}
if let Some(d) = new_duration {
TOTAL_DURATION.save(deps.storage, &d)?;
}
Ok(Response::new().add_attribute("method", "governance_override"))
}
8.3 紧急暂停
基于 CW2 的 Pausable 模式:
use cw_controllers::PauseInfo;
use cw_pause_once::PauseOnce;
const PAUSE: PauseOnce = PauseOnce::new();
#[entry_point]
pub fn execute(deps: DepsMut, env: Env, info: MessageInfo, msg: ExecuteMsg) -> StdResult<Response> {
if PAUSE.is_paused(deps.storage)? {
return Err(StdError::generic_err("Contract is paused"));
}
// ...
}
fn execute_pause(deps: DepsMut, info: MessageInfo) -> StdResult<Response> {
let admin = ADMIN.load(deps.storage)?;
if info.sender != admin { return Err(StdError::generic_err("Unauthorized")); }
PAUSE.pause(deps.storage)?;
Ok(Response::new().add_attribute("method", "pause"))
}
8.4 审计清单
部署前检查:
- [ ] 悬崖期 ≤ 总时长
- [ ] 总时长 > 0
- [ ] 代币地址有效
- [ ] 受益人地址有效
- [ ] 可撤销 Vesting 必须提供管理员
- [ ] 管理员地址使用多签
- [ ] 合约代码已审计
- [ ] 测试覆盖率 ≥ 90%
- [ ] 在 testnet 上验证过完整流程
- [ ] 检查 Gas 消耗是否合理
8.5 Gas 优化建议
// 1. 使用 Uint128 的 multiply_ratio 替代除法
// 推荐 (精确):
let vested = total.multiply_ratio(elapsed, total_dur);
// 不推荐 (可能溢出或精度问题):
// let vested = total * Uint128::from(elapsed) / Uint128::from(total_dur);
// 2. 批量释放时限制单次操作数量
// 建议: limit ≤ 20
// 3. 使用查询代替存储读取
// OK: 频繁读取状态用 Item
// 避免: 循环中重复读取存储
// 4. MSG Chain Gas 参考
// 简单查询: ~500,000 gas ≈ 0.0125 umsg
// 释放操作: ~800,000 gas ≈ 0.02 umsg
// 合约实例化: ~2,000,000 gas ≈ 0.05 umsg
9. 附录
9.1 代码仓库结构
vesting-contracts/
├── src/
│ ├── contracts/
│ │ ├── timelock.rs # Timelock 控制器
│ │ ├── token_vesting.rs # TokenVesting 合约
│ │ └── vesting_factory.rs # Vesting 工厂
│ ├── lib.rs
│ └── bin/
│ └── vesting_cli.py # Python CLI 工具
├── frontend/
│ ├── components/
│ │ ├── VestingDashboard.tsx
│ │ ├── VestingProgress.tsx
│ │ └── VestingButton.tsx
│ ├── hooks/
│ │ └── useVesting.ts
│ └── types/
│ └── vesting.ts
├── tests/
│ └── integration_tests.rs
├── Cargo.toml
└── README.md
9.2 Cargo.toml
[package]
name = "msg-chain-vesting"
version = "1.0.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
cosmwasm-std = "1.5"
cosmwasm-schema = "1.5"
cw2 = "1.1"
cw20 = "1.1"
cw-storage-plus = "1.2"
cw-controllers = "1.1"
schemars = "0.8"
serde = { version = "1.0", features = ["derive"] }
sha256 = "1.5"
thiserror = "1.0"
[dev-dependencies]
cosmwasm-vm = "1.5"
[profile.release]
opt-level = 3
lto = true
debug = false
9.3 时间换算表
| 时间 | 秒 | MSG Chain 区块 |
|---|---|---|
| 1 分钟 | 60 | 12 |
| 1 小时 | 3,600 | 720 |
| 1 天 | 86,400 | 17,280 |
| 1 周 | 604,800 | 120,960 |
| 1 月 (~30天) | 2,592,000 | 518,400 |
| 3 月 (~90天) | 7,776,000 | 1,555,200 |
| 6 月 (~180天) | 15,552,000 | 3,110,400 |
| 1 年 (~365天) | 31,536,000 | 6,307,200 |
| 2 年 (~730天) | 63,072,000 | 12,614,400 |
| 3 年 (~1095天) | 94,608,000 | 18,921,600 |
| 4 年 (~1460天) | 126,144,000 | 25,228,800 |
9.4 常见问题排查
问题: release 返回 "No tokens available to release"
原因: 可能仍在悬崖期内,或总代币量为0
解决: 查询 elapsed_time 确认时间,调用 fund 检查余额
问题: "Vesting has been revoked"
原因: 管理员已撤销该 Vesting
解决: 联系管理员确认撤销原因
问题: 交易失败 out of gas
原因: 批量释放量过大
解决: 减小 limit 参数,单次不超过 20
问题: "Delay too short"
原因: 调度的 delay 小于 min_delay
解决: 增加 delay 或先更新 min_delay
9.5 参考链接
9.6 完整集成测试
// tests/integration_tests.rs
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
use cosmwasm_std::{Addr, Timestamp, Uint128};
// 集成测试:完整 Vesting 生命周期
#[test]
fn test_full_vesting_lifecycle() {
let mut deps = mock_dependencies();
// 1. 实例化 Vesting 合约
let instantiate_msg = token_vesting::InstantiateMsg {
token: "cw20_token".to_string(),
beneficiary: "beneficiary".to_string(),
cliff_duration: 100, // 100秒悬崖期
total_duration: 1000, // 1000秒总时长
start_time: Some(1000),
revocable: true,
admin: Some("admin".to_string()),
};
token_vesting::instantiate(
deps.as_mut(),
mock_env(),
mock_info("deployer", &[]),
instantiate_msg,
).unwrap();
// 2. 存入代币
token_vesting::TOTAL_AMOUNT
.save(deps.as_mut().storage, &Uint128::new(1_000_000))
.unwrap();
// 3. 在悬崖期内尝试释放 → 应该失败
let mut env = mock_env();
env.block.time = Timestamp::from_seconds(1050); // elapsed=50, cliff=100
let err = token_vesting::execute(
deps.as_mut(),
env.clone(),
mock_info("beneficiary", &[]),
token_vesting::ExecuteMsg::Release {},
).unwrap_err();
assert!(err.to_string().contains("No tokens available"));
// 4. 越过悬崖期后释放
env.block.time = Timestamp::from_seconds(1500); // elapsed=500, 50%
let res = token_vesting::execute(
deps.as_mut(),
env.clone(),
mock_info("beneficiary", &[]),
token_vesting::ExecuteMsg::Release {},
).unwrap();
assert_eq!(res.attributes[0].value, "release");
let released1 = token_vesting::RELEASED_AMOUNT
.load(deps.as_ref().storage)
.unwrap();
assert_eq!(released1, Uint128::new(500_000));
// 5. 查询已释放量
let res = token_vesting::query(
deps.as_ref(),
env.clone(),
token_vesting::QueryMsg::ReleasedAmount {},
).unwrap();
let q: token_vesting::ReleasedAmountResponse =
cosmwasm_std::from_binary(&res).unwrap();
assert_eq!(q.released, Uint128::new(500_000));
// 6. 剩余代币继续释放
env.block.time = Timestamp::from_seconds(2000); // elapsed=1000, 100%
let res = token_vesting::execute(
deps.as_mut(),
env.clone(),
mock_info("beneficiary", &[]),
token_vesting::ExecuteMsg::Release {},
).unwrap();
assert_eq!(res.attributes[0].value, "release");
let released2 = token_vesting::RELEASED_AMOUNT
.load(deps.as_ref().storage)
.unwrap();
assert_eq!(released2, Uint128::new(1_000_000));
// 7. 尝试再次释放 → 应该失败(已全部释放)
let err = token_vesting::execute(
deps.as_mut(),
env.clone(),
mock_info("beneficiary", &[]),
token_vesting::ExecuteMsg::Release {},
).unwrap_err();
assert!(err.to_string().contains("No tokens available"));
}
// 集成测试:撤销场景
#[test]
fn test_revoke_before_completion() {
let mut deps = mock_dependencies();
// 实例化可撤销 Vesting
token_vesting::instantiate(
deps.as_mut(),
mock_env(),
mock_info("deployer", &[]),
token_vesting::InstantiateMsg {
token: "cw20_token".to_string(),
beneficiary: "beneficiary".to_string(),
cliff_duration: 100,
total_duration: 1000,
start_time: Some(1000),
revocable: true,
admin: Some("admin".to_string()),
},
).unwrap();
token_vesting::TOTAL_AMOUNT
.save(deps.as_mut().storage, &Uint128::new(1_000_000))
.unwrap();
let mut env = mock_env();
env.block.time = Timestamp::from_seconds(1500); // 50% vested
// 释放 50%
token_vesting::execute(
deps.as_mut(),
env.clone(),
mock_info("beneficiary", &[]),
token_vesting::ExecuteMsg::Release {},
).unwrap();
// 管理员撤销
let res = token_vesting::execute(
deps.as_mut(),
env.clone(),
mock_info("admin", &[]),
token_vesting::ExecuteMsg::Revoke {},
).unwrap();
assert_eq!(res.attributes[0].value, "revoke");
// 撤销后受益人不能再释放
let err = token_vesting::execute(
deps.as_mut(),
env.clone(),
mock_info("beneficiary", &[]),
token_vesting::ExecuteMsg::Release {},
).unwrap_err();
assert!(err.to_string().contains("has been revoked"));
}
// 集成测试:时间锁完整流程
#[test]
fn test_timelock_full_flow() {
let mut deps = mock_dependencies();
let admin = "admin";
let executor = "executor";
// 实例化 Timelock
timelock::instantiate(
deps.as_mut(),
mock_env(),
mock_info("deployer", &[]),
timelock::InstantiateMsg {
min_delay: 3600,
admin: admin.to_string(),
executor: executor.to_string(),
},
).unwrap();
let target = "target_contract".to_string();
let call_data = Binary::from(b"some_execute_data".as_slice());
let op_id = timelock::compute_operation_id(&target, &call_data);
// 调度操作
let mut env = mock_env();
let info = mock_info("admin", &[]);
timelock::execute(
deps.as_mut(),
env.clone(),
info,
timelock::ExecuteMsg::Schedule {
target: target.clone(),
msg: call_data.clone(),
delay: 7200,
},
).unwrap();
// 验证已调度
let is_scheduled = timelock::query_is_scheduled(
deps.as_ref(),
op_id.clone(),
).unwrap();
assert!(is_scheduled.scheduled);
// 在延迟期内执行 → 失败
let err = timelock::execute(
deps.as_mut(),
env.clone(),
mock_info("executor", &[]),
timelock::ExecuteMsg::Execute {
target: target.clone(),
msg: call_data.clone(),
},
).unwrap_err();
assert!(err.to_string().contains("Operation not ready"));
// 跳过延迟期
env.block.time = env.block.time.plus_seconds(7201);
// 执行
let res = timelock::execute(
deps.as_mut(),
env.clone(),
mock_info("executor", &[]),
timelock::ExecuteMsg::Execute {
target: target.clone(),
msg: call_data.clone(),
},
).unwrap();
assert_eq!(res.attributes[0].value, "execute");
}
9.7 前端 CSS 样式
/* styles/vesting.css */
.vesting-dashboard {
max-width: 800px;
margin: 0 auto;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.dashboard-header h2 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.refresh-btn {
padding: 8px 16px;
background: #e5e7eb;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.vesting-card {
padding: 16px;
border-radius: 8px;
background: #f9fafb;
border: 1px solid #e5e7eb;
}
.card-title {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.card-value {
font-size: 1.25rem;
font-weight: 700;
color: #111827;
}
.card-blue { border-left: 3px solid #3b82f6; }
.card-green { border-left: 3px solid #10b981; }
.card-orange { border-left: 3px solid #f59e0b; }
.vesting-progress {
margin-bottom: 24px;
}
.progress-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 8px;
}
.progress-track {
position: relative;
height: 16px;
background: #e5e7eb;
border-radius: 8px;
overflow: hidden;
}
.progress-fill {
height: 100%;
transition: width 0.3s ease;
border-radius: 8px;
}
.progress-fill.cliff {
background: linear-gradient(90deg, #fbbf24, #f59e0b);
}
.progress-fill.releasing {
background: linear-gradient(90deg, #60a5fa, #3b82f6);
}
.cliff-marker {
position: absolute;
top: -4px;
width: 2px;
height: calc(100% + 8px);
background: #ef4444;
z-index: 5;
}
.cliff-label {
position: absolute;
top: -16px;
left: -12px;
font-size: 0.625rem;
color: #ef4444;
white-space: nowrap;
}
.progress-ticks {
position: relative;
height: 0;
}
.tick {
position: absolute;
font-size: 0.625rem;
color: #9ca3af;
}
.time-info {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #6b7280;
margin-top: 8px;
}
.vesting-details {
margin-bottom: 24px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
font-size: 0.875rem;
}
.detail-row code {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.75rem;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.release-btn {
width: 100%;
padding: 12px 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.release-btn:hover {
background: #2563eb;
}
.release-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.release-btn.loading {
opacity: 0.7;
cursor: wait;
}
.release-success {
margin-top: 12px;
padding: 12px;
background: #ecfdf5;
border: 1px solid #10b981;
border-radius: 8px;
color: #065f46;
text-align: center;
}
.release-success a {
color: #3b82f6;
text-decoration: underline;
margin-left: 8px;
}
.release-error {
margin-top: 12px;
padding: 12px;
background: #fef2f2;
border: 1px solid #ef4444;
border-radius: 8px;
color: #991b1b;
text-align: center;
}
.release-not-allowed {
padding: 12px;
background: #fffbeb;
border: 1px solid #f59e0b;
border-radius: 8px;
color: #92400e;
text-align: center;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
.spinner-small {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
9.8 Docker 部署脚本
# Dockerfile
FROM rust:1.75-slim as builder
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# 编译合约
RUN rustup target add wasm32-unknown-unknown
RUN RUSTFLAGS='-C link-arg=-s' cargo build --release --target wasm32-unknown-unknown
# 优化
FROM cosmwasm/wasm-optimizer:0.14.0 as optimizer
COPY --from=builder /app/target/wasm32-unknown-unknown/release/*.wasm /app/
RUN wasm-optimizer /app/*.wasm -o /optimized/
# 最终镜像
FROM alpine:3.19
COPY --from=optimizer /optimized/*.wasm /wasm/
COPY scripts/deploy.sh /deploy.sh
RUN chmod +x /deploy.sh
CMD ["/deploy.sh"]
#!/bin/bash
# scripts/deploy.sh
# MSG Chain 合约部署脚本
set -e
CHAIN_ID="${CHAIN_ID:-msg-chain-1}"
RPC="${RPC:-https://rpc.msgchain.org:443}"
FROM="${FROM:-deployer}"
GAS_PRICES="1000000000attoMSG"
GAS_ADJUSTMENT="1.3"
echo "=== MSG Chain 合约部署 ==="
echo "Chain: $CHAIN_ID"
echo "RPC: $RPC"
# 1. 上传 Timelock 合约
echo ""
echo ">>> 上传 Timelock 合约..."
TIMELOCK_RESULT=$(msgd tx wasm store /wasm/timelock.wasm \
--from $FROM --gas auto --gas-prices $GAS_PRICES \
--gas-adjustment $GAS_ADJUSTMENT --chain-id $CHAIN_ID \
--node $RPC -y --output json)
TIMELOCK_CODE_ID=$(echo $TIMELOCK_RESULT | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Timelock Code ID: $TIMELOCK_CODE_ID"
# 2. 上传 TokenVesting 合约
echo ""
echo ">>> 上传 TokenVesting 合约..."
VESTING_RESULT=$(msgd tx wasm store /wasm/token_vesting.wasm \
--from $FROM --gas auto --gas-prices $GAS_PRICES \
--gas-adjustment $GAS_ADJUSTMENT --chain-id $CHAIN_ID \
--node $RPC -y --output json)
VESTING_CODE_ID=$(echo $VESTING_RESULT | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "TokenVesting Code ID: $VESTING_CODE_ID"
# 3. 上传 VestingFactory 合约
echo ""
echo ">>> 上传 VestingFactory 合约..."
FACTORY_RESULT=$(msgd tx wasm store /wasm/vesting_factory.wasm \
--from $FROM --gas auto --gas-prices $GAS_PRICES \
--gas-adjustment $GAS_ADJUSTMENT --chain-id $CHAIN_ID \
--node $RPC -y --output json)
FACTORY_CODE_ID=$(echo $FACTORY_RESULT | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "VestingFactory Code ID: $FACTORY_CODE_ID"
# 4. 实例化 Timelock
echo ""
echo ">>> 实例化 Timelock..."
ADMIN_ADDR=$(msgd keys show $FROM --bech val -a)
msgd tx wasm instantiate $TIMELOCK_CODE_ID \
'{"min_delay":86400,"admin":"'$ADMIN_ADDR'","executor":"'$ADMIN_ADDR'"}' \
--label "timelock-v1" --from $FROM --gas auto \
--gas-prices $GAS_PRICES --gas-adjustment $GAS_ADJUSTMENT \
--chain-id $CHAIN_ID --node $RPC -y
# 5. 实例化 VestingFactory
echo ""
echo ">>> 实例化 VestingFactory..."
msgd tx wasm instantiate $FACTORY_CODE_ID \
'{"vesting_code_id":'$VESTING_CODE_ID',"admin":"'$ADMIN_ADDR'","token":"'$TOKEN_ADDR'"}' \
--label "vesting-factory-v1" --from $FROM --gas auto \
--gas-prices $GAS_PRICES --gas-adjustment $GAS_ADJUSTMENT \
--chain-id $CHAIN_ID --node $RPC -y
echo ""
echo "=== 部署完成 ==="
echo "Timelock Code ID: $TIMELOCK_CODE_ID"
echo "TokenVesting Code ID: $VESTING_CODE_ID"
echo "VestingFactory Code ID: $FACTORY_CODE_ID"
9.9 合约升级与迁移
当 Vesting 合约需要升级时,使用 CosmWasm 的 migratable 模式:
// src/contracts/token_vesting.rs
use cosmwasm_std::entry_point;
#[entry_point]
pub fn migrate(deps: DepsMut, _env: Env, _msg: Empty) -> StdResult<Response> {
let ver = cw2::get_contract_version(deps.storage)?;
// 仅允许从已知旧版本升级
if ver.contract != CONTRACT_NAME {
return Err(StdError::generic_err("Cannot migrate from different contract type"));
}
// 更新版本
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
Ok(Response::new().add_attribute("method", "migrate"))
}
9.10 测试网验证清单
| 步骤 | 命令 | 预期结果 |
|---|---|---|
| 上传合约 | msgd tx wasm store |
返回 code_id |
| 实例化 | msgd tx wasm instantiate |
返回 contract_address |
| 转入代币 | msgd tx wasm execute transfer |
余额更新 |
| fund | msgd tx wasm execute fund |
total_amount 更新 |
| 查询 | msgd query wasm contract-state smart |
返回正确信息 |
| 释放 | msgd tx wasm execute release |
受益人收到代币 |
| 撤销 | msgd tx wasm execute revoke |
退回未释放代币 |
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
