dApp Docs/时间锁与代币锁仓Vesting实现
Development reference. Not independently verified for production.

时间锁与代币锁仓 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. 概述
  2. Timelock 控制器实现
  3. TokenVesting 合约实现
  4. VestingSchedule 工厂
  5. CLI 操作
  6. 前端集成
  7. 常见场景与代码示例
  8. 安全与合规
  9. 附录

1. 概述

1.1 为什么需要锁仓与 Vesting

在区块链项目中,代币分发机制直接影响项目的长期健康发展。锁仓(Timelock)与代币归属(Vesting)是两种核心工具:

机制 目的 适用对象
Timelock 延迟代币或合约操作的执行时间 团队分配、合约升级、治理提案
Vesting 按时间表逐步释放代币 团队成员、顾问、投资者、空投

MSG Chain 代币经济背景:

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 计划。核心优势:

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
受益人权益 安全,合约不可更改 可能被管理员取消
管理员权限 无 可收回未释放代币
适用场景 空投、公开销售、投资者 团队、顾问、激励计划
信任假设 无需信任管理员 需要信任管理员不滥用权限

最佳实践:

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 审计清单

部署前检查:

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/