dApp Docs/收益农场StakingRewards实现
Development reference. Not independently verified for production.

MSG Chain 收益农场与 Staking Rewards 实现指南

适用链: msg-chain-1
Bech32 前缀: msg
精度: 18 decimals
Gas 价格: 1,000,000,000 attoMSG/gas
出块时间: ~5 秒
主网状态: No-Go


目录

  1. 概述
  2. StakingRewards 合约
  3. 奖励分发机制
  4. 多资产农场
  5. 锁仓加强版
  6. 前端实现
  7. 安全考虑
  8. 完整示例
  9. 附录

1. 概述

1.1 什么是 Yield Farming / Staking Rewards

Yield Farming(收益农耕)是 DeFi 中最核心的激励机制之一。用户将加密资产(如 LP 代币、单一代币)质押到智能合约中,获得以本协议代币或手续费形式发放的奖励。Staking Rewards(质押奖励)是其中最常见的形式,用户只需存入资产并持有,即可按比例获得持续发放的奖励。

在 MSG Chain 上,所有合约均使用 CosmWasm 框架开发,以 Rust 语言编写。本文将完整演示从零到开发参考级的 Staking Rewards 实现。

1.2 常见模式

LP 代币质押

用户提供流动性 → 获得 LP 代币 → 质押 LP 代币到农场 → 获得 $MSG 奖励

这是最主流的模式。用户向 AMM DEX 注入流动性,DEX 返回代表其仓位份额的 LP 代币。用户将这些 LP 代币质押到 StakingRewards 合约中,合约按质押占比发放奖励。

单一资产质押

用户存入 $MSG → 获得质押凭证 → 按时间累积奖励

又称"单币质押"或"储蓄池"。用户仅需持有协议原生代币或指定 CW20 代币即可参与,风险更低。

多资产农场

用户存入 资产A / 资产B / LP_TOKEN_X → 各池独立计算 → 统一发放 $MSG

MasterChef 模式:一个合约管理多个质押池,每个池可有不同的奖励倍率。

锁仓加强版

用户锁仓 $veMSG → 锁定时间越长 → 投票权 + 奖励乘数越高

Curve 的 vote-escrowed 模型。用户将代币锁仓 1 周到 4 年不等,获得 veNFT,其投票权和奖励乘数与锁仓时长线性相关。

1.3 奖励分发方式

Per-Block 分发

每个区块发放固定数量的奖励。
reward_rate = 每个区块的奖励代币数

适合出块时间稳定的链。MSG Chain 约 5 秒一个区块,日均约 17280 个区块。

Per-Second 分发

每秒发放固定数量的奖励。
reward_rate = 每秒的奖励代币数

不受出块时间波动影响,更精确。本文主要采用此方式。

1.4 核心概念:Rewards Per Share

奖励计算的核心公式基于"每股累积奖励"(Reward Per Token Stored):

reward_per_token_stored(t2) = reward_per_token_stored(t1)
    + (reward_rate * (t2 - t1) * 10^18) / total_supply

用户获得的奖励:

earned(user) = balance_of(user)
    * (reward_per_token_stored - user_reward_per_token_paid[user])
    / 10^18
    + rewards[user]

这是一个经典的"快照式"会计模型:

这种设计的优点:

  1. 用户无需手动触发奖励计算,任何交互(存款、取款、领取)都会自动更新。
  2. 全局变量消耗固定存储,不随用户数增长。
  3. 精度由 10^18 缩放因子保证。

1.5 MSG Chain 上的合约部署流程

编写合约 → 优化编译 → 上传 WASM → 实例化 → 前端交互
# 编译
RUSTFLAGS='-C link-args=-s' cargo build --release --target wasm32-unknown-unknown

# 优化(推荐使用 cosmwasm/optimizer)
docker run --rm -v "$(pwd)":/code cosmwasm/optimizer:0.15.0

# 上传
msgd tx wasm store staking_rewards.wasm --from wallet --gas auto --gas-prices 1000000000attoMSG

# 实例化
msgd tx wasm instantiate 1 '{"staking_token":"msg1...","rewards_token":"msg1...","reward_rate":"1000000000000000000"}' --from wallet --label "StakingRewards" --gas auto --gas-prices 1000000000attoMSG

2. StakingRewards 合约

2.1 合约架构

合约由以下文件构成:

contracts/staking-rewards/
├── Cargo.toml
├── src/
│   ├── contract.rs       # 入口:instantiate, execute, query
│   ├── state.rs          # 存储状态
│   ├── error.rs          # 错误类型
│   ├── helpers.rs        # 辅助函数
│   ├── math.rs           # 精确计算
│   └── msg.rs            # 消息定义
└── tests/
    └── integration.rs    # 集成测试

2.2 状态定义 (state.rs)

use cosmwasm_std::{Addr, Uint128, Timestamp};
use cw_storage_plus::{Item, Map};

/// 合约配置 —— 一经初始化不可更改
pub const CONFIG: Item<Config> = Item::new("config");

/// 全局奖励状态
pub const REWARD_STATE: Item<RewardState> = Item::new("reward_state");

/// 用户奖励状态
pub const USER_REWARDS: Map<&Addr, UserReward> = Map::new("user_rewards");

/// 用户余额(质押数量)
pub const USER_BALANCES: Map<&Addr, Uint128> = Map::new("user_balances");

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct Config {
    /// 质押代币(CW20 地址)
    pub staking_token: Addr,
    /// 奖励代币(CW20 地址)
    pub rewards_token: Addr,
    /// 奖励分发者(可更新 reward_rate)
    pub rewards_distributor: Addr,
    /// 持续时间(秒)
    pub duration: u64,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct RewardState {
    /// 每 period 的奖励速率
    pub reward_rate: Uint128,
    /// 奖励发放结束时间
    pub period_finish: Timestamp,
    /// 上次更新时间
    pub last_update_time: Timestamp,
    /// 每股累积奖励(缩放因子 1e18)
    pub reward_per_token_stored: Uint128,
    /// 总质押量
    pub total_supply: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, Default)]
pub struct UserReward {
    /// 用户每股已付奖励快照
    pub reward_per_token_paid: Uint128,
    /// 用户已赚取但未领取的奖励
    pub rewards: Uint128,
}

2.3 消息定义 (msg.rs)

use cosmwasm_std::Uint128;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct InstantiateMsg {
    pub staking_token: String,
    pub rewards_token: String,
    pub rewards_distributor: String,
    pub duration: u64,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
    Stake { amount: Uint128 },
    Withdraw { amount: Uint128 },
    ClaimRewards {},
    NotifyRewardAmount { reward_rate: Uint128 },
    EmergencyWithdraw { recipient: Option<String> },
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
    Config {},
    RewardState {},
    StakedBalance { address: String },
    PendingRewards { address: String },
    UserInfo { address: String },
    Apr {},
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct ConfigResponse {
    pub staking_token: String,
    pub rewards_token: String,
    pub rewards_distributor: String,
    pub duration: u64,
    pub staking_token_decimals: u8,
    pub rewards_token_decimals: u8,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct RewardStateResponse {
    pub reward_rate: Uint128,
    pub period_finish: String,
    pub last_update_time: String,
    pub reward_per_token_stored: Uint128,
    pub total_supply: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct StakedBalanceResponse {
    pub address: String,
    pub balance: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct PendingRewardsResponse {
    pub address: String,
    pub pending_rewards: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct UserInfoResponse {
    pub address: String,
    pub staked_amount: Uint128,
    pub pending_rewards: Uint128,
    pub reward_per_token_paid: Uint128,
    pub rewards: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct AprResponse {
    pub apr: String,
    pub is_active: bool,
}

2.4 错误类型 (error.rs)

use cosmwasm_std::StdError;
use thiserror::Error;

#[derive(Error, Debug, PartialEq)]
pub enum ContractError {
    #[error("{0}")]
    Std(#[from] StdError),

    #[error("未授权: 发送者不是奖励分发者")]
    Unauthorized {},

    #[error("质押数量为零")]
    ZeroStake {},

    #[error("取出数量超过余额")]
    InsufficientStake {},

    #[error("奖励周期已过期")]
    RewardPeriodExpired {},

    #[error("奖励周期仍在进行中, 无法覆盖更新")]
    RewardPeriodActive {},

    #[error("算术溢出")]
    Overflow {},

    #[error("代币转移失败")]
    TokenTransferFailed {},

    #[error("无效输入")]
    InvalidInput {},
}

2.5 数学计算 (math.rs)

use cosmwasm_std::Uint128;
use crate::error::ContractError;

/// 精度缩放因子: 1e18
pub const PRECISION: Uint128 = Uint128::new(1_000_000_000_000_000_000);

/// 安全加法
pub fn checked_add(a: Uint128, b: Uint128) -> Result<Uint128, ContractError> {
    a.checked_add(b).ok_or(ContractError::Overflow {})
}

/// 安全减法
pub fn checked_sub(a: Uint128, b: Uint128) -> Result<Uint128, ContractError> {
    a.checked_sub(b).ok_or(ContractError::Overflow {})
}

/// 安全乘法
pub fn checked_mul(a: Uint128, b: Uint128) -> Result<Uint128, ContractError> {
    a.checked_mul(b).ok_or(ContractError::Overflow {})
}

/// 安全除法
pub fn checked_div(a: Uint128, b: Uint128) -> Result<Uint128, ContractError> {
    a.checked_div(b).ok_or(ContractError::Overflow {})
}

/// 计算每股累积奖励
/// reward_per_token_stored += reward_rate * time_elapsed * PRECISION / total_supply
pub fn calculate_reward_per_token(
    reward_per_token_stored: Uint128,
    reward_rate: Uint128,
    total_supply: Uint128,
    time_elapsed: u64,
) -> Uint128 {
    if total_supply.is_zero() || time_elapsed == 0 {
        return reward_per_token_stored;
    }
    let reward = reward_rate * Uint128::from(time_elapsed);
    let reward_per_token_add = reward * PRECISION / total_supply;
    reward_per_token_stored + reward_per_token_add
}

/// 计算待领取奖励
/// earned = balance * (reward_per_token_stored - user_reward_per_token_paid) / PRECISION + rewards
pub fn calculate_earned(
    balance: Uint128,
    reward_per_token_stored: Uint128,
    user_reward_per_token_paid: Uint128,
    rewards: Uint128,
) -> Result<Uint128, ContractError> {
    let per_token_diff = checked_sub(reward_per_token_stored, user_reward_per_token_paid)?;
    let new_rewards = checked_div(
        checked_mul(balance, per_token_diff)?,
        PRECISION,
    )?;
    checked_add(new_rewards, rewards)
}

/// 计算 APR
/// apr = reward_rate * 365 * 86400 / total_supply * 100%
pub fn calculate_apr(
    reward_rate: Uint128,
    total_supply: Uint128,
) -> String {
    if total_supply.is_zero() || reward_rate.is_zero() {
        return "0.00".to_string();
    }
    let yearly_reward = reward_rate
        * Uint128::from(365u64 * 86400u64);
    let apr = yearly_reward
        * Uint128::from(100u64)
        / total_supply;
    let apr_u128: u128 = apr.into();
    let integer_part = apr_u128 / 100;
    let decimal_part = apr_u128 % 100;
    format!("{}.{:02}", integer_part, decimal_part)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_calculate_reward_per_token() {
        let stored = Uint128::zero();
        let rate = Uint128::new(1000);
        let supply = Uint128::new(100_000);
        let elapsed = 100u64;
        let result = calculate_reward_per_token(stored, rate, supply, elapsed);
        assert_eq!(result, Uint128::new(1_000_000_000_000_000_000u128));
    }

    #[test]
    fn test_calculate_reward_per_token_zero_supply() {
        let stored = Uint128::new(500);
        let rate = Uint128::new(1000);
        let supply = Uint128::zero();
        let elapsed = 100u64;
        let result = calculate_reward_per_token(stored, rate, supply, elapsed);
        assert_eq!(result, stored);
    }

    #[test]
    fn test_calculate_earned() {
        let balance = Uint128::new(50_000);
        let stored = Uint128::new(2_000_000_000_000_000_000u128);
        let paid = Uint128::new(1_000_000_000_000_000_000u128);
        let rewards = Uint128::zero();
        let earned = calculate_earned(balance, stored, paid, rewards).unwrap();
        assert_eq!(earned, Uint128::new(50_000));
    }

    #[test]
    fn test_calculate_apr() {
        let rate = Uint128::new(1_000_000);
        let supply = Uint128::new(100_000_000);
        let apr = calculate_apr(rate, supply);
        assert_eq!(apr, "315360.00");
    }
}

2.6 合约入口 (contract.rs)

use cosmwasm_std::{
    entry_point, to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut,
    Env, MessageInfo, Response, StdResult, Timestamp, Uint128, WasmMsg,
    StdError,
};
use cw2::set_contract_version;
use cw20::{Cw20ExecuteMsg, Cw20QueryMsg, BalanceResponse};

use crate::error::ContractError;
use crate::math::{
    calculate_apr, calculate_earned, calculate_reward_per_token,
    checked_add, checked_sub,
};
use crate::msg::{
    AprResponse, ConfigResponse, ExecuteMsg, InstantiateMsg, PendingRewardsResponse,
    QueryMsg, RewardStateResponse, StakedBalanceResponse, UserInfoResponse,
};
use crate::state::{
    Config, RewardState, UserReward, CONFIG, REWARD_STATE, USER_BALANCES, USER_REWARDS,
};

const CONTRACT_NAME: &str = "msgchain-staking-rewards";
const CONTRACT_VERSION: &str = "1.0.0";

#[entry_point]
pub fn instantiate(
    deps: DepsMut,
    _env: Env,
    _info: MessageInfo,
    msg: InstantiateMsg,
) -> Result<Response, ContractError> {
    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;

    let staking_token = deps.api.addr_validate(&msg.staking_token)?;
    let rewards_token = deps.api.addr_validate(&msg.rewards_token)?;
    let rewards_distributor = deps.api.addr_validate(&msg.rewards_distributor)?;

    if msg.duration == 0 {
        return Err(ContractError::InvalidInput {});
    }

    let config = Config {
        staking_token,
        rewards_token,
        rewards_distributor,
        duration: msg.duration,
    };
    CONFIG.save(deps.storage, &config)?;

    let reward_state = RewardState {
        reward_rate: Uint128::zero(),
        period_finish: Timestamp::default(),
        last_update_time: Timestamp::default(),
        reward_per_token_stored: Uint128::zero(),
        total_supply: Uint128::zero(),
    };
    REWARD_STATE.save(deps.storage, &reward_state)?;

    Ok(Response::new()
        .add_attribute("method", "instantiate")
        .add_attribute("staking_token", config.staking_token)
        .add_attribute("rewards_token", config.rewards_token)
        .add_attribute("duration", config.duration.to_string()))
}

#[entry_point]
pub fn execute(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> Result<Response, ContractError> {
    match msg {
        ExecuteMsg::Stake { amount } => execute_stake(deps, env, info, amount),
        ExecuteMsg::Withdraw { amount } => execute_withdraw(deps, env, info, amount),
        ExecuteMsg::ClaimRewards {} => execute_claim_rewards(deps, env, info),
        ExecuteMsg::NotifyRewardAmount { reward_rate } => {
            execute_notify_reward_amount(deps, env, info, reward_rate)
        }
        ExecuteMsg::EmergencyWithdraw { recipient } => {
            execute_emergency_withdraw(deps, env, info, recipient)
        }
    }
}

/// 更新全局奖励状态
fn update_reward_state(
    deps: &mut DepsMut,
    env: &Env,
) -> Result<RewardState, ContractError> {
    let mut state = REWARD_STATE.load(deps.storage)?;
    let config = CONFIG.load(deps.storage)?;

    let current_time = env.block.time;

    let time_elapsed = if current_time < state.period_finish {
        current_time.seconds() - state.last_update_time.seconds()
    } else {
        if state.last_update_time < state.period_finish {
            state.period_finish.seconds() - state.last_update_time.seconds()
        } else {
            0
        }
    };

    if !state.total_supply.is_zero() && time_elapsed > 0 {
        state.reward_per_token_stored = calculate_reward_per_token(
            state.reward_per_token_stored,
            state.reward_rate,
            state.total_supply,
            time_elapsed,
        );
    }

    if current_time >= state.period_finish {
        state.reward_rate = Uint128::zero();
    }

    state.last_update_time = current_time;
    REWARD_STATE.save(deps.storage, &state)?;

    Ok(state)
}

/// 更新单个用户的奖励快照
fn update_user_reward(
    deps: &mut DepsMut,
    env: &Env,
    address: &Addr,
) -> Result<UserReward, ContractError> {
    let state = update_reward_state(deps, env)?;
    let balance = USER_BALANCES
        .may_load(deps.storage, address)?
        .unwrap_or_default();
    let mut user_reward = USER_REWARDS
        .may_load(deps.storage, address)?
        .unwrap_or_default();

    user_reward.rewards = calculate_earned(
        balance,
        state.reward_per_token_stored,
        user_reward.reward_per_token_paid,
        user_reward.rewards,
    )?;

    user_reward.reward_per_token_paid = state.reward_per_token_stored;
    USER_REWARDS.save(deps.storage, address, &user_reward)?;

    Ok(user_reward)
}

// -------------------------------------------------------------------
// Stake
// -------------------------------------------------------------------
fn execute_stake(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    amount: Uint128,
) -> Result<Response, ContractError> {
    if amount.is_zero() {
        return Err(ContractError::ZeroStake {});
    }

    let config = CONFIG.load(deps.storage)?;

    update_user_reward(&mut *deps, &env, &info.sender)?;

    let mut balance = USER_BALANCES
        .may_load(deps.storage, &info.sender)?
        .unwrap_or_default();
    balance = checked_add(balance, amount)?;
    USER_BALANCES.save(deps.storage, &info.sender, &balance)?;

    let mut state = REWARD_STATE.load(deps.storage)?;
    state.total_supply = checked_add(state.total_supply, amount)?;
    REWARD_STATE.save(deps.storage, &state)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::TransferFrom {
            owner: info.sender.to_string(),
            recipient: env.contract.address.to_string(),
            amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "stake")
        .add_attribute("user", info.sender)
        .add_attribute("amount", amount))
}

// -------------------------------------------------------------------
// Withdraw
// -------------------------------------------------------------------
fn execute_withdraw(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    amount: Uint128,
) -> Result<Response, ContractError> {
    if amount.is_zero() {
        return Err(ContractError::ZeroStake {});
    }

    let config = CONFIG.load(deps.storage)?;

    let balance = USER_BALANCES
        .may_load(deps.storage, &info.sender)?
        .unwrap_or_default();
    if balance < amount {
        return Err(ContractError::InsufficientStake {});
    }

    update_user_reward(&mut *deps, &env, &info.sender)?;

    let mut new_balance = USER_BALANCES
        .may_load(deps.storage, &info.sender)?
        .unwrap_or_default();
    new_balance = checked_sub(new_balance, amount)?;
    USER_BALANCES.save(deps.storage, &info.sender, &new_balance)?;

    let mut state = REWARD_STATE.load(deps.storage)?;
    state.total_supply = checked_sub(state.total_supply, amount)?;
    REWARD_STATE.save(deps.storage, &state)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::Transfer {
            recipient: info.sender.to_string(),
            amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "withdraw")
        .add_attribute("user", info.sender)
        .add_attribute("amount", amount))
}

// -------------------------------------------------------------------
// Claim Rewards
// -------------------------------------------------------------------
fn execute_claim_rewards(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;

    let user_reward = update_user_reward(&mut *deps, &env, &info.sender)?;

    let reward_amount = user_reward.rewards;
    if reward_amount.is_zero() {
        return Err(ContractError::ZeroStake {});
    }

    let mut updated_user_reward = user_reward;
    updated_user_reward.rewards = Uint128::zero();
    USER_REWARDS.save(deps.storage, &info.sender, &updated_user_reward)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.rewards_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::Transfer {
            recipient: info.sender.to_string(),
            amount: reward_amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "claim_rewards")
        .add_attribute("user", info.sender)
        .add_attribute("amount", reward_amount))
}

// -------------------------------------------------------------------
// Notify Reward Amount
// -------------------------------------------------------------------
fn execute_notify_reward_amount(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    reward_rate: Uint128,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    if info.sender != config.rewards_distributor {
        return Err(ContractError::Unauthorized {});
    }

    let state = REWARD_STATE.load(deps.storage)?;
    if env.block.time < state.period_finish {
        return Err(ContractError::RewardPeriodActive {});
    }

    let mut new_state = update_reward_state(&mut *deps, &env)?;

    new_state.reward_rate = reward_rate;
    new_state.period_finish = env.block.time.plus_seconds(config.duration);
    new_state.last_update_time = env.block.time;
    REWARD_STATE.save(deps.storage, &new_state)?;

    Ok(Response::new()
        .add_attribute("method", "notify_reward_amount")
        .add_attribute("reward_rate", reward_rate)
        .add_attribute("period_finish", new_state.period_finish.to_string()))
}

// -------------------------------------------------------------------
// Emergency Withdraw
// -------------------------------------------------------------------
fn execute_emergency_withdraw(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    recipient: Option<String>,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    if info.sender != config.rewards_distributor {
        return Err(ContractError::Unauthorized {});
    }

    let state = REWARD_STATE.load(deps.storage)?;
    let total_supply = state.total_supply;
    let send_to = recipient.unwrap_or_else(|| config.rewards_distributor.to_string());

    let mut new_state = state;
    new_state.total_supply = Uint128::zero();
    REWARD_STATE.save(deps.storage, &new_state)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::Transfer {
            recipient: send_to.clone(),
            amount: total_supply,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "emergency_withdraw")
        .add_attribute("recipient", send_to)
        .add_attribute("amount", total_supply))
}

// -------------------------------------------------------------------
// Query
// -------------------------------------------------------------------
#[entry_point]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
    match msg {
        QueryMsg::Config {} => to_binary(&query_config(deps)?),
        QueryMsg::RewardState {} => to_binary(&query_reward_state(deps)?),
        QueryMsg::StakedBalance { address } => {
            to_binary(&query_staked_balance(deps, address)?)
        }
        QueryMsg::PendingRewards { address } => {
            to_binary(&query_pending_rewards(deps, address)?)
        }
        QueryMsg::UserInfo { address } => to_binary(&query_user_info(deps, address)?),
        QueryMsg::Apr {} => to_binary(&query_apr(deps)?),
    }
}

fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
    let config = CONFIG.load(deps.storage)?;
    Ok(ConfigResponse {
        staking_token: config.staking_token.to_string(),
        rewards_token: config.rewards_token.to_string(),
        rewards_distributor: config.rewards_distributor.to_string(),
        duration: config.duration,
        staking_token_decimals: 18,
        rewards_token_decimals: 18,
    })
}

fn query_reward_state(deps: Deps) -> StdResult<RewardStateResponse> {
    let state = REWARD_STATE.load(deps.storage)?;
    Ok(RewardStateResponse {
        reward_rate: state.reward_rate,
        period_finish: state.period_finish.to_string(),
        last_update_time: state.last_update_time.to_string(),
        reward_per_token_stored: state.reward_per_token_stored,
        total_supply: state.total_supply,
    })
}

fn query_staked_balance(deps: Deps, address: String) -> StdResult<StakedBalanceResponse> {
    let addr = deps.api.addr_validate(&address)?;
    let balance = USER_BALANCES
        .may_load(deps.storage, &addr)?
        .unwrap_or_default();
    Ok(StakedBalanceResponse { address, balance })
}

fn query_pending_rewards(deps: Deps, address: String) -> StdResult<PendingRewardsResponse> {
    let addr = deps.api.addr_validate(&address)?;
    let config = CONFIG.load(deps.storage)?;
    let state = REWARD_STATE.load(deps.storage)?;
    let balance = USER_BALANCES
        .may_load(deps.storage, &addr)?
        .unwrap_or_default();
    let user_reward = USER_REWARDS
        .may_load(deps.storage, &addr)?
        .unwrap_or_default();

    let current_time = deps.querier.query_block_info()?.time;
    let time_elapsed = if current_time < state.period_finish {
        current_time.seconds() - state.last_update_time.seconds()
    } else {
        if state.last_update_time < state.period_finish {
            state.period_finish.seconds() - state.last_update_time.seconds()
        } else {
            0
        }
    };

    let current_reward_per_token = calculate_reward_per_token(
        state.reward_per_token_stored,
        state.reward_rate,
        state.total_supply,
        time_elapsed,
    );

    let pending = calculate_earned(
        balance,
        current_reward_per_token,
        user_reward.reward_per_token_paid,
        user_reward.rewards,
    )
    .unwrap_or(Uint128::zero());

    Ok(PendingRewardsResponse {
        address,
        pending_rewards: pending,
    })
}

fn query_user_info(deps: Deps, address: String) -> StdResult<UserInfoResponse> {
    let addr = deps.api.addr_validate(&address)?;
    let balance = USER_BALANCES
        .may_load(deps.storage, &addr)?
        .unwrap_or_default();
    let user_reward = USER_REWARDS
        .may_load(deps.storage, &addr)?
        .unwrap_or_default();
    let state = REWARD_STATE.load(deps.storage)?;

    let current_time = deps.querier.query_block_info()?.time;
    let time_elapsed = if current_time < state.period_finish {
        current_time.seconds() - state.last_update_time.seconds()
    } else {
        if state.last_update_time < state.period_finish {
            state.period_finish.seconds() - state.last_update_time.seconds()
        } else {
            0
        }
    };

    let current_reward_per_token = calculate_reward_per_token(
        state.reward_per_token_stored,
        state.reward_rate,
        state.total_supply,
        time_elapsed,
    );

    let pending = calculate_earned(
        balance,
        current_reward_per_token,
        user_reward.reward_per_token_paid,
        user_reward.rewards,
    )
    .unwrap_or(Uint128::zero());

    Ok(UserInfoResponse {
        address,
        staked_amount: balance,
        pending_rewards: pending,
        reward_per_token_paid: user_reward.reward_per_token_paid,
        rewards: user_reward.rewards,
    })
}

fn query_apr(deps: Deps) -> StdResult<AprResponse> {
    let state = REWARD_STATE.load(deps.storage)?;
    let current_time = deps.querier.query_block_info()?.time;
    let is_active = current_time < state.period_finish && !state.reward_rate.is_zero();
    let apr = if is_active {
        calculate_apr(state.reward_rate, state.total_supply)
    } else {
        "0.00".to_string()
    };
    Ok(AprResponse { apr, is_active })
}

2.7 Cargo.toml

[package]
name = "msgchain-staking-rewards"
version = "1.0.0"
edition = "2021"
description = "MSG Chain Staking Rewards Contract"

[lib]
crate-type = ["cdylib", "rlib"]

[features]
backtraces = ["cosmwasm-std/backtraces"]
library = []

[dependencies]
cosmwasm-std = { version = "1.5", features = ["staking"] }
cosmwasm-storage = "1.5"
cw-storage-plus = "1.2"
cw2 = "1.1"
cw20 = "1.1"
schemars = "0.8"
serde = { version = "1.0", default-features = false, features = ["derive"] }
thiserror = "1.0"

[dev-dependencies]
cosmwasm-schema = "1.5"
cw-multi-test = "1.2"
anyhow = "1.0"

2.8 helpers.rs

use cosmwasm_std::{Addr, QuerierWrapper, StdResult, Uint128};
use cw20::{Cw20QueryMsg, BalanceResponse};

pub fn query_token_balance(
    querier: &QuerierWrapper,
    token_addr: &Addr,
    account_addr: &Addr,
) -> StdResult<Uint128> {
    let resp: BalanceResponse = querier.query_wasm_smart(
        token_addr.to_string(),
        &Cw20QueryMsg::Balance {
            address: account_addr.to_string(),
        },
    )?;
    Ok(resp.balance)
}

2.9 集成测试

use cosmwasm_std::{Addr, Uint128};
use cw_multi_test::{App, ContractWrapper, Executor};
use msgchain_staking_rewards::msg::{
    ConfigResponse, ExecuteMsg, InstantiateMsg, PendingRewardsResponse,
    QueryMsg, StakedBalanceResponse, UserInfoResponse,
};

fn instantiate_staking_rewards(app: &mut App) -> (Addr, Addr, Addr) {
    let owner = Addr::unchecked("owner");

    let staking_token_id = app.store_code(ContractWrapper::new(
        cw20_base::entry::execute,
        cw20_base::entry::instantiate,
        cw20_base::entry::query,
    ));
    let staking_token = app
        .instantiate_contract(
            staking_token_id,
            owner.clone(),
            &cw20_base::msg::InstantiateMsg {
                name: "Staking Token".to_string(),
                symbol: "STK".to_string(),
                decimals: 18,
                initial_balances: vec![cw20::Cw20Coin {
                    address: "user".to_string(),
                    amount: Uint128::new(1_000_000_000_000_000_000_000u128),
                }],
                mint: None,
                marketing: None,
            },
            &[],
            "staking_token",
            None,
        )
        .unwrap();

    let rewards_token = app
        .instantiate_contract(
            staking_token_id,
            owner.clone(),
            &cw20_base::msg::InstantiateMsg {
                name: "Rewards Token".to_string(),
                symbol: "REW".to_string(),
                decimals: 18,
                initial_balances: vec![cw20::Cw20Coin {
                    address: "owner".to_string(),
                    amount: Uint128::new(10_000_000_000_000_000_000_000u128),
                }],
                mint: None,
                marketing: None,
            },
            &[],
            "rewards_token",
            None,
        )
        .unwrap();

    let contract_code_id = app.store_code(ContractWrapper::new(
        msgchain_staking_rewards::contract::execute,
        msgchain_staking_rewards::contract::instantiate,
        msgchain_staking_rewards::contract::query,
    ));

    let staking_rewards = app
        .instantiate_contract(
            contract_code_id,
            owner.clone(),
            &InstantiateMsg {
                staking_token: staking_token.to_string(),
                rewards_token: rewards_token.to_string(),
                rewards_distributor: owner.to_string(),
                duration: 7 * 86400u64,
            },
            &[],
            "staking_rewards",
            None,
        )
        .unwrap();

    (staking_rewards, staking_token, rewards_token)
}

#[test]
fn test_full_flow() {
    let mut app = App::default();
    let owner = Addr::unchecked("owner");
    let user = Addr::unchecked("user");

    let (staking_rewards, staking_token, rewards_token) =
        instantiate_staking_rewards(&mut app);

    app.execute_contract(
        user.clone(),
        staking_token.clone(),
        &cw20_base::msg::ExecuteMsg::IncreaseAllowance {
            spender: staking_rewards.to_string(),
            amount: Uint128::new(500_000_000_000_000_000_000u128),
            expires: None,
        },
        &[],
    )
    .unwrap();

    app.execute_contract(
        owner.clone(),
        staking_rewards.clone(),
        &ExecuteMsg::NotifyRewardAmount {
            reward_rate: Uint128::new(1000),
        },
        &[],
    )
    .unwrap();

    let stake_amount = Uint128::new(100_000_000_000_000_000_000u128);
    app.execute_contract(
        user.clone(),
        staking_rewards.clone(),
        &ExecuteMsg::Stake { amount: stake_amount },
        &[],
    )
    .unwrap();

    app.update_block(|block| {
        block.time = block.time.plus_seconds(100);
        block.height += 20;
    });

    let pending: PendingRewardsResponse = app
        .wrap()
        .query_wasm_smart(
            staking_rewards.clone(),
            &QueryMsg::PendingRewards {
                address: user.to_string(),
            },
        )
        .unwrap();
    assert_eq!(pending.pending_rewards, Uint128::new(100_000));

    app.execute_contract(
        user.clone(),
        staking_rewards.clone(),
        &ExecuteMsg::ClaimRewards {},
        &[],
    )
    .unwrap();

    app.execute_contract(
        user.clone(),
        staking_rewards.clone(),
        &ExecuteMsg::Withdraw {
            amount: Uint128::new(50_000_000_000_000_000_000u128),
        },
        &[],
    )
    .unwrap();

    let user_info: UserInfoResponse = app
        .wrap()
        .query_wasm_smart(
            staking_rewards,
            &QueryMsg::UserInfo {
                address: user.to_string(),
            },
        )
        .unwrap();
    assert_eq!(user_info.staked_amount, Uint128::new(50_000_000_000_000_000_000u128));
}

#[test]
fn test_multiple_users() {
    let mut app = App::default();
    let owner = Addr::unchecked("owner");
    let alice = Addr::unchecked("alice");
    let bob = Addr::unchecked("bob");

    let (staking_rewards, staking_token, _rewards_token) =
        instantiate_staking_rewards(&mut app);

    for user in [&alice, &bob] {
        app.execute_contract(
            owner.clone(),
            staking_token.clone(),
            &cw20_base::msg::ExecuteMsg::Transfer {
                recipient: user.to_string(),
                amount: Uint128::new(100_000_000_000_000_000_000u128),
            },
            &[],
        )
        .unwrap();

        app.execute_contract(
            user.clone(),
            staking_token.clone(),
            &cw20_base::msg::ExecuteMsg::IncreaseAllowance {
                spender: staking_rewards.to_string(),
                amount: Uint128::new(100_000_000_000_000_000_000u128),
                expires: None,
            },
            &[],
        )
        .unwrap();
    }

    app.execute_contract(
        owner.clone(),
        staking_rewards.clone(),
        &ExecuteMsg::NotifyRewardAmount {
            reward_rate: Uint128::new(100),
        },
        &[],
    )
    .unwrap();

    app.execute_contract(
        alice.clone(),
        staking_rewards.clone(),
        &ExecuteMsg::Stake {
            amount: Uint128::new(100_000_000_000_000_000_000u128),
        },
        &[],
    )
    .unwrap();

    app.execute_contract(
        bob.clone(),
        staking_rewards.clone(),
        &ExecuteMsg::Stake {
            amount: Uint128::new(50_000_000_000_000_000_000u128),
        },
        &[],
    )
    .unwrap();

    app.update_block(|block| {
        block.time = block.time.plus_seconds(200);
        block.height += 40;
    });

    let alice_pending: PendingRewardsResponse = app
        .wrap()
        .query_wasm_smart(
            staking_rewards.clone(),
            &QueryMsg::PendingRewards {
                address: alice.to_string(),
            },
        )
        .unwrap();
    let bob_pending: PendingRewardsResponse = app
        .wrap()
        .query_wasm_smart(
            staking_rewards,
            &QueryMsg::PendingRewards {
                address: bob.to_string(),
            },
        )
        .unwrap();

    assert!(alice_pending.pending_rewards.u128() > 13_300);
    assert!(alice_pending.pending_rewards.u128() < 13_400);
    assert!(bob_pending.pending_rewards.u128() > 6_600);
    assert!(bob_pending.pending_rewards.u128() < 6_700);

    let total = alice_pending.pending_rewards + bob_pending.pending_rewards;
    assert_eq!(total, Uint128::new(20_000));
}

3. 奖励分发机制

3.1 奖励速率计算

奖励在每个"周期"内以恒定速率发放。管理员调用 NotifyRewardAmount 来设置每秒奖励速率。

场景: 每周发放 100,000 个 $MSG

奖励总额    = 100,000 MSG = 100,000 * 10^18 = 1e23
周期时长    = 7 天 = 604,800 秒
每秒奖励速率 = 1e23 / 604,800 = 165,343,915,343,915,344

Rust 实现:

pub fn calculate_reward_rate(
    total_rewards: Uint128,
    duration: u64,
) -> Uint128 {
    if duration == 0 {
        return Uint128::zero();
    }
    total_rewards / Uint128::from(duration)
}

3.2 每次操作时更新奖励

为了保证公平性,任何状态变更操作(质押、取款、领取)必须先更新全局和用户的奖励状态。

函数调用流程:

execute_stake()
  -> update_reward_state()        # 1. 更新全局 reward_per_token_stored
     -> calculate_reward_per_token()
  -> update_user_reward()         # 2. 更新用户的奖励快照
     -> calculate_earned()
  -> 更新用户余额
  -> 更新 total_supply
  -> 发送转账消息

3.3 快照式会计 (Snapshot Accounting)

传统方法是在每次发放奖励时遍历所有用户。这在用户量大了之后 Gas 消耗过高(O(n) 复杂度)。

快照法将计算复杂度降为 O(1):

全局状态 (RewardState):
  reward_per_token_stored  全局变量, 随每个区块递增
  total_supply             当前总质押量

用户状态 (UserReward):
  reward_per_token_paid    用户最后一次操作时的快照值
  rewards                  已记录但未领取的奖励

用户应得奖励:
  earned = balance * (reward_per_token_stored - reward_per_token_paid) / PRECISION + rewards

关键性质: 用户在任意时刻的待领取奖励,都可以通过上述公式直接从状态中推导得出,无需遍历其他用户。

3.4 未领取奖励的追踪

rewards[user] 字段累积了用户尚未领取的奖励。

fn update_user_reward(deps, env, user) -> UserReward {
    let state = update_reward_state(deps, env);
    let balance = load_balance(user);
    let mut user_reward = load_user_reward(user);

    // 计算自上次快照以来的新增奖励
    let new_rewards = balance
        * (state.reward_per_token_stored - user_reward.reward_per_token_paid)
        / PRECISION;

    // 累加到用户的 rewards 中
    user_reward.rewards += new_rewards;

    // 更新快照
    user_reward.reward_per_token_paid = state.reward_per_token_stored;

    save_user_reward(user, user_reward);
    user_reward
}

3.5 两种场景: Compounding vs Manual Claim

手动领取 (Manual Claim)
用户主动调用 ClaimRewards 将累积奖励提到钱包。奖励不会自动复投。

fn execute_claim_rewards(deps, env, info) {
    let user_reward = update_user_reward(deps, env, info.sender);
    let amount = user_reward.rewards;
    user_reward.rewards = 0;
    save_user_reward(user, user_reward);
    transfer(rewards_token, info.sender, amount);
}

自动复投 (Auto-Compounding)
在 Stake 或 Withdraw 操作时,自动将累积奖励转换为质押代币再投入。

fn execute_stake_with_compound(deps, env, info, amount) {
    let user_reward = update_user_reward(deps, env, info.sender);

    if user_reward.rewards > 0 {
        let compound_amount = user_reward.rewards;
        user_reward.rewards = 0;
        // 将 compound_amount 计入用户余额
        balance += compound_amount;
        total_supply += compound_amount;
    }

    balance += amount;
    total_supply += amount;
}

3.6 奖励分发脚本 (Python)

#!/usr/bin/env python3
"""奖励分发脚本: 定期向 StakingRewards 合约注入奖励"""

import time
from decimal import Decimal

# MSG Chain 配置
RPC_URL = "https://rpc.msgchain.org"
CHAIN_ID = "msg-chain-1"
GAS_PRICE = "1000000000attoMSG"
GAS_LIMIT = "500000"

# 合约地址
STAKING_REWARDS = "msg1stak..."
DISTRIBUTOR_KEY = "path/to/key.json"

# 奖励参数
REWARD_PER_WEEK = Decimal("100000")  # 每周 100,000 MSG
DURATION_SECONDS = 7 * 86400  # 7 天


def calculate_reward_rate(amount: Decimal, duration: int) -> Decimal:
    """计算每秒奖励速率"""
    amount_atto = amount * Decimal("1e18")
    return amount_atto // duration


def distribute_rewards():
    """向 StakingRewards 合约注入新一周期的奖励"""
    rate = calculate_reward_rate(REWARD_PER_WEEK, DURATION_SECONDS)
    print(f"奖励速率: {rate} 每秒 (约 {REWARD_PER_WEEK} MSG / 周)")

    execute_msg = {
        "notify_reward_amount": {
            "reward_rate": str(rate)
        }
    }

    # TODO: 使用 Cosmos SDK 签名广播
    # tx = wallet.sign_and_broadcast(contract_address, execute_msg)
    print(f"已提交奖励更新: {rate} / 秒")


def check_and_auto_distribute():
    """自动化: 检查当前奖励是否即将结束,自动续期"""
    pass


if __name__ == "__main__":
    distribute_rewards()

3.7 奖励时间线示例

时间线:

T0:   部署合约, 设置奖励速率为 100 REW/s, 周期为 7 天
      reward_per_token_stored = 0
      total_supply = 0

T1:   Alice 存入 100 STK
      此时 total_supply = 0, 不累积奖励
      total_supply = 100
      Alice.reward_per_token_paid = 0

T2:   T1 + 10 秒, Bob 存入 50 STK
      全局更新:
        reward_per_token_stored += 100 * 10 * 1e18 / 100 = 10e18
      Alice 更新:
        新增奖励 = 100 * (10e18 - 0) / 1e18 = 1,000 REW
        Alice.rewards = 1,000
        Alice.reward_per_token_paid = 10e18
      total_supply = 150

T3:   T2 + 20 秒, Alice 取出 50 STK
      全局更新:
        reward_per_token_stored += 100 * 20 * 1e18 / 150 = 13.33e18
        = 23.33e18
      Alice 更新:
        新增奖励 = 150 * (23.33e18 - 10e18) / 1e18 = 2,000 REW
        Alice.rewards = 1,000 + 2,000 = 3,000
      Alice 取出 50, balance = 100
      total_supply = 100

T4:   T3 + 30 秒, Alice 领取奖励
      全局更新:
        reward_per_token_stored += 100 * 30 * 1e18 / 100 = 30e18
        = 53.33e18
      Alice 更新:
        新增奖励 = 100 * (53.33e18 - 23.33e18) / 1e18 = 3,000
        Alice.rewards = 3,000 + 3,000 = 6,000
      Alice 领取 6,000 REW
        Alice.rewards = 0

3.8 多周期奖励分发

当管理员连续多次调用 NotifyRewardAmount 时,合约需要正确处理周期的衔接。

fn execute_notify_reward_amount_v2(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    reward_rate: Uint128,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    if info.sender != config.rewards_distributor {
        return Err(ContractError::Unauthorized {});
    }

    let mut state = REWARD_STATE.load(deps.storage)?;

    // 先更新到当前时间
    state = update_reward_state_raw(deps.storage, &env, &state);

    // 如果当前奖励期仍在进行中, 将未发放的奖励累加到下一期
    if env.block.time < state.period_finish {
        let remaining = state.period_finish.seconds() - env.block.time.seconds();
        let leftover = state.reward_rate * Uint128::from(remaining);

        // 新的 reward_rate 需要覆盖剩余奖励 + 新增奖励
        let new_total_rate = reward_rate
            + leftover / Uint128::from(config.duration);
        state.reward_rate = new_total_rate;
    } else {
        state.reward_rate = reward_rate;
    }

    state.period_finish = env.block.time.plus_seconds(config.duration);
    state.last_update_time = env.block.time;
    REWARD_STATE.save(deps.storage, &state)?;

    Ok(Response::new()
        .add_attribute("method", "notify_reward_amount")
        .add_attribute("reward_rate", state.reward_rate))
}

4. 多资产农场

4.1 MasterChef 架构

MasterChef 模式源自 SushiSwap,核心思想是一个合约管理多个质押池,每个池可以质押不同的代币,所有池共享同一个奖励代币。

MasterChef 合约
|-- Pool #0: STK-OSMO LP (奖励倍率 1x)
|-- Pool #1: MSG-OSMO LP (奖励倍率 3x)
|-- Pool #2: USDC (奖励倍率 0.5x)
|-- Pool #3: WBTC (奖励倍率 2x)
|-- ...
  每个池独立追踪: staking_token, total_supply, reward_per_token_stored
  全局: 每个区块发放固定奖励, 按各池权重分配

4.2 MasterChef 状态定义

use cosmwasm_std::{Addr, Uint128, Timestamp};
use cw_storage_plus::{Item, Map};

pub const CONFIG: Item<MasterChefConfig> = Item::new("config");
pub const GLOBAL_STATE: Item<GlobalRewardState> = Item::new("global_state");
pub const POOLS: Map<u32, PoolInfo> = Map::new("pools");
pub const USER_POOL_REWARDS: Map<(u32, &Addr), UserPoolReward> = Map::new("user_pool");
pub const USER_POOL_BALANCES: Map<(u32, &Addr), Uint128> = Map::new("user_balance");
pub const POOL_COUNT: Item<u32> = Item::new("pool_count");

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct MasterChefConfig {
    pub rewards_token: Addr,
    pub owner: Addr,
    pub rewards_distributor: Addr,
    pub precision: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct GlobalRewardState {
    pub reward_per_block: Uint128,
    pub last_update_block: u64,
    pub accumulated_reward_per_share: Uint128,
    pub total_alloc_point: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct PoolInfo {
    pub pool_id: u32,
    pub staking_token: Addr,
    pub alloc_point: Uint128,
    pub reward_per_token_stored: Uint128,
    pub total_supply: Uint128,
    pub last_update_time: Timestamp,
    pub active: bool,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, Default)]
pub struct UserPoolReward {
    pub reward_per_token_paid: Uint128,
    pub rewards: Uint128,
}

4.3 MasterChef 消息定义

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct MasterChefInstantiateMsg {
    pub rewards_token: String,
    pub owner: String,
    pub rewards_distributor: String,
    pub reward_per_block: Uint128,
    pub precision: Option<Uint128>,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MasterChefExecuteMsg {
    AddPool { staking_token: String, alloc_point: Uint128 },
    UpdatePool { pool_id: u32, alloc_point: Uint128 },
    Deposit { pool_id: u32, amount: Uint128 },
    Withdraw { pool_id: u32, amount: Uint128 },
    ClaimRewards { pool_id: u32 },
    SetRewardPerBlock { reward_per_block: Uint128 },
    EmergencyWithdraw { pool_id: u32 },
}

4.4 MasterChef 核心实现

use cosmwasm_std::{
    entry_point, to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut,
    Env, MessageInfo, Response, StdResult, Timestamp, Uint128, WasmMsg,
};
use cw20::Cw20ExecuteMsg;

use crate::error::ContractError;
use crate::math::{checked_add, checked_mul, checked_div, checked_sub};
use crate::msg::*;
use crate::state::*;

const PRECISION: Uint128 = Uint128::new(1_000_000_000_000_000_000u128);

#[entry_point]
pub fn instantiate(
    deps: DepsMut,
    _env: Env,
    _info: MessageInfo,
    msg: MasterChefInstantiateMsg,
) -> Result<Response, ContractError> {
    let config = MasterChefConfig {
        rewards_token: deps.api.addr_validate(&msg.rewards_token)?,
        owner: deps.api.addr_validate(&msg.owner)?,
        rewards_distributor: deps.api.addr_validate(&msg.rewards_distributor)?,
        precision: msg.precision.unwrap_or(PRECISION),
    };
    CONFIG.save(deps.storage, &config)?;

    let global = GlobalRewardState {
        reward_per_block: msg.reward_per_block,
        last_update_block: 0,
        accumulated_reward_per_share: Uint128::zero(),
        total_alloc_point: Uint128::zero(),
    };
    GLOBAL_STATE.save(deps.storage, &global)?;

    POOL_COUNT.save(deps.storage, &0u32)?;

    Ok(Response::new()
        .add_attribute("method", "instantiate")
        .add_attribute("rewards_token", config.rewards_token))
}

fn mass_update_pools(
    deps: &mut DepsMut,
    env: &Env,
) -> Result<GlobalRewardState, ContractError> {
    let mut global = GLOBAL_STATE.load(deps.storage)?;
    let current_block = env.block.height;
    let block_diff = current_block - global.last_update_block;

    if block_diff == 0 {
        return Ok(global);
    }

    if !global.total_alloc_point.is_zero() {
        let total_reward = checked_mul(
            global.reward_per_block,
            Uint128::from(block_diff),
        )?;
        global.accumulated_reward_per_share =
            checked_add(global.accumulated_reward_per_share, total_reward)?;
    }

    global.last_update_block = current_block;
    GLOBAL_STATE.save(deps.storage, &global)?;
    Ok(global)
}

fn update_pool(
    deps: &mut DepsMut,
    env: &Env,
    pool_id: u32,
) -> Result<PoolInfo, ContractError> {
    let global = mass_update_pools(deps, env)?;
    let mut pool = POOLS.load(deps.storage, pool_id)?;
    let config = CONFIG.load(deps.storage)?;

    let current_time = env.block.time;
    let time_elapsed = current_time.seconds() - pool.last_update_time.seconds();

    if time_elapsed > 0 && !pool.total_supply.is_zero() && !global.total_alloc_point.is_zero() {
        let pool_reward = checked_mul(
            global.accumulated_reward_per_share,
            pool.alloc_point,
        )?;
        let reward_per_share = checked_div(
            checked_mul(pool_reward, config.precision)?,
            checked_mul(pool.total_supply, global.total_alloc_point)?,
        )?;
        pool.reward_per_token_stored =
            checked_add(pool.reward_per_token_stored, reward_per_share)?;
    }

    pool.last_update_time = current_time;
    POOLS.save(deps.storage, pool_id, &pool)?;
    Ok(pool)
}

fn update_user_reward_in_pool(
    deps: &mut DepsMut,
    env: &Env,
    pool_id: u32,
    user: &Addr,
) -> Result<UserPoolReward, ContractError> {
    let pool = update_pool(deps, env, pool_id)?;
    let config = CONFIG.load(deps.storage)?;

    let balance = USER_POOL_BALANCES
        .may_load(deps.storage, (pool_id, user))?
        .unwrap_or_default();
    let mut user_reward = USER_POOL_REWARDS
        .may_load(deps.storage, (pool_id, user))?
        .unwrap_or_default();

    let reward_diff = checked_sub(
        pool.reward_per_token_stored,
        user_reward.reward_per_token_paid,
    )?;
    let new_rewards = checked_div(
        checked_mul(balance, reward_diff)?,
        config.precision,
    )?;
    user_reward.rewards = checked_add(user_reward.rewards, new_rewards)?;
    user_reward.reward_per_token_paid = pool.reward_per_token_stored;

    USER_POOL_REWARDS.save(deps.storage, (pool_id, user), &user_reward)?;
    Ok(user_reward)
}

fn execute_deposit(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    pool_id: u32,
    amount: Uint128,
) -> Result<Response, ContractError> {
    let pool = POOLS.load(deps.storage, pool_id)?;
    let config = CONFIG.load(deps.storage)?;

    if !pool.active {
        return Err(ContractError::PoolNotActive {});
    }

    update_user_reward_in_pool(&mut *deps, &env, pool_id, &info.sender)?;

    let mut balance = USER_POOL_BALANCES
        .may_load(deps.storage, (pool_id, &info.sender))?
        .unwrap_or_default();
    balance = checked_add(balance, amount)?;
    USER_POOL_BALANCES.save(deps.storage, (pool_id, &info.sender), &balance)?;

    let mut new_pool = pool;
    new_pool.total_supply = checked_add(new_pool.total_supply, amount)?;
    POOLS.save(deps.storage, pool_id, &new_pool)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: pool.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::TransferFrom {
            owner: info.sender.to_string(),
            recipient: env.contract.address.to_string(),
            amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "deposit")
        .add_attribute("pool_id", pool_id.to_string())
        .add_attribute("user", info.sender)
        .add_attribute("amount", amount))
}

fn execute_withdraw_mc(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    pool_id: u32,
    amount: Uint128,
) -> Result<Response, ContractError> {
    let pool = POOLS.load(deps.storage, pool_id)?;

    let balance = USER_POOL_BALANCES
        .may_load(deps.storage, (pool_id, &info.sender))?
        .unwrap_or_default();
    if balance < amount {
        return Err(ContractError::InsufficientStake {});
    }

    update_user_reward_in_pool(&mut *deps, &env, pool_id, &info.sender)?;

    let mut new_balance = balance;
    new_balance = checked_sub(new_balance, amount)?;
    USER_POOL_BALANCES.save(deps.storage, (pool_id, &info.sender), &new_balance)?;

    let mut new_pool = pool;
    new_pool.total_supply = checked_sub(new_pool.total_supply, amount)?;
    POOLS.save(deps.storage, pool_id, &new_pool)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: new_pool.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::Transfer {
            recipient: info.sender.to_string(),
            amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "withdraw")
        .add_attribute("pool_id", pool_id.to_string())
        .add_attribute("user", info.sender)
        .add_attribute("amount", amount))
}

fn execute_claim_rewards_mc(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    pool_id: u32,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;

    let user_reward = update_user_reward_in_pool(
        &mut *deps, &env, pool_id, &info.sender,
    )?;

    let reward_amount = user_reward.rewards;
    if reward_amount.is_zero() {
        return Err(ContractError::ZeroRewards {});
    }

    let mut updated = user_reward;
    updated.rewards = Uint128::zero();
    USER_POOL_REWARDS.save(deps.storage, (pool_id, &info.sender), &updated)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.rewards_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::Transfer {
            recipient: info.sender.to_string(),
            amount: reward_amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "claim_rewards")
        .add_attribute("pool_id", pool_id.to_string())
        .add_attribute("user", info.sender)
        .add_attribute("amount", reward_amount))
}

fn execute_add_pool(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    staking_token: String,
    alloc_point: Uint128,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    if info.sender != config.owner {
        return Err(ContractError::Unauthorized {});
    }

    let mut pool_count = POOL_COUNT.load(deps.storage)?;
    pool_count += 1;

    let pool = PoolInfo {
        pool_id: pool_count,
        staking_token: deps.api.addr_validate(&staking_token)?,
        alloc_point,
        reward_per_token_stored: Uint128::zero(),
        total_supply: Uint128::zero(),
        last_update_time: Timestamp::default(),
        active: true,
    };
    POOLS.save(deps.storage, pool_count, &pool)?;
    POOL_COUNT.save(deps.storage, &pool_count)?;

    let mut global = GLOBAL_STATE.load(deps.storage)?;
    global.total_alloc_point = checked_add(global.total_alloc_point, alloc_point)?;
    GLOBAL_STATE.save(deps.storage, &global)?;

    Ok(Response::new()
        .add_attribute("method", "add_pool")
        .add_attribute("pool_id", pool_count.to_string())
        .add_attribute("staking_token", staking_token)
        .add_attribute("alloc_point", alloc_point))
}

fn execute_emergency_withdraw_mc(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    pool_id: u32,
) -> Result<Response, ContractError> {
    let pool = POOLS.load(deps.storage, pool_id)?;

    let balance = USER_POOL_BALANCES
        .may_load(deps.storage, (pool_id, &info.sender))?
        .unwrap_or_default();

    if balance.is_zero() {
        return Err(ContractError::ZeroStake {});
    }

    let mut new_pool = pool;
    new_pool.total_supply = checked_sub(new_pool.total_supply, balance)?;
    POOLS.save(deps.storage, pool_id, &new_pool)?;

    USER_POOL_BALANCES.remove(deps.storage, (pool_id, &info.sender));

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: pool.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::Transfer {
            recipient: info.sender.to_string(),
            amount: balance,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "emergency_withdraw")
        .add_attribute("pool_id", pool_id.to_string())
        .add_attribute("user", info.sender)
        .add_attribute("amount", balance))
}

4.5 MasterChef 部署脚本 (Python)

#!/usr/bin/env python3
"""MasterChef 多资产农场部署脚本"""

import json
from pathlib import Path

CHAIN_ID = "msg-chain-1"
NODE_URL = "https://rpc.msgchain.org"
GAS_PRICE = "1000000000attoMSG"
GAS_LIMIT = 2_000_000


def setup_wallet():
    """初始化钱包 (CosmPy)"""
    # from cosmpy.aerial.wallet import LocalWallet
    # wallet = LocalWallet.from_mnemonic("...")
    pass


def upload_masterchef(client, wallet) -> int:
    """上传 MasterChef WASM 二进制"""
    # contract = CosmWasmContract(Path("artifacts/masterchef.wasm").read_bytes())
    # tx = contract.upload(client, wallet, gas_limit=GAS_LIMIT)
    # print(f"Code ID: {tx.code_id}")
    # return tx.code_id
    return 1


def instantiate_masterchef(client, wallet, code_id, rewards_token, reward_per_block) -> str:
    """实例化 MasterChef"""
    # contract = CosmWasmContract(code_id=code_id, instantiate_msg={...}, label="MasterChef-v1")
    # tx = contract.instantiate(client, wallet, gas_limit=GAS_LIMIT)
    # return tx.contract_address
    pass


def configure_pools():
    """配置示例池"""
    pass


if __name__ == "__main__":
    configure_pools()

5. 锁仓加强版

5.1 veToken 模型

veToken (vote-escrowed token) 模型由 Curve Finance 推广。用户将代币锁仓以获得 veNFT,锁仓时间越长,获得的投票权和奖励乘数越高。

与传统 Staking 的区别:

特性 普通 Staking veToken Staking
锁仓 无锁或软锁 固定期限锁仓
流动性 可随时提取 锁仓期内不可提取
乘数 统一 1x 1x ~ 4x 取决于锁仓时长
NFT 表示 无 每个仓位是一个 NFT
转让 不可转让 veNFT 可转让

5.2 veToken 合约状态

use cosmwasm_std::{Addr, Uint128, Timestamp};
use cw_storage_plus::{Item, Map};

pub const CONFIG: Item<VeConfig> = Item::new("config");
pub const REWARD_STATE: Item<VeRewardState> = Item::new("reward_state");
pub const USER_REWARDS: Map<&Addr, VeUserReward> = Map::new("user_rewards");
pub const POSITIONS: Map<u64, Position> = Map::new("positions");
pub const USER_POSITIONS: Map<&Addr, Vec<u64>> = Map::new("user_positions");
pub const NEXT_POSITION_ID: Item<u64> = Item::new("next_pos_id");

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct VeConfig {
    pub staking_token: Addr,
    pub rewards_token: Addr,
    pub max_lock_time: u64,
    pub min_lock_time: u64,
    pub rewards_distributor: Addr,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct VeRewardState {
    pub reward_rate: Uint128,
    pub period_finish: Timestamp,
    pub last_update_time: Timestamp,
    pub reward_per_token_stored: Uint128,
    pub total_supply: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct Position {
    pub position_id: u64,
    pub owner: Addr,
    pub amount: Uint128,
    pub start: Timestamp,
    pub end: Timestamp,
    pub weighted_amount: Uint128,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, Default)]
pub struct VeUserReward {
    pub reward_per_token_paid: Uint128,
    pub rewards: Uint128,
}

5.3 锁仓乘数计算

核心公式:锁仓时间越长,乘数越高。

乘数 = 1 + (锁仓时间 / 最大锁仓时间) * (最大乘数 - 最小乘数)

示例:
  最大锁仓 = 4 年 (1461 天)
  最小乘数 = 1x
  最大乘数 = 4x

  锁仓 1 年  -> 乘数 = 1 + (365/1461) * 3 = 1.75x
  锁仓 2 年  -> 乘数 = 1 + (730/1461) * 3 = 2.50x
  锁仓 4 年  -> 乘数 = 1 + (1461/1461) * 3 = 4.00x
pub const MAX_MULTIPLIER: Uint128 = Uint128::new(4_000_000_000_000_000_000u128); // 4x
pub const MIN_MULTIPLIER: Uint128 = Uint128::new(1_000_000_000_000_000_000u128); // 1x

pub fn calculate_multiplier(
    lock_duration: u64,
    max_lock_time: u64,
) -> Uint128 {
    if max_lock_time == 0 || lock_duration == 0 {
        return MIN_MULTIPLIER;
    }

    let lock = Uint128::from(lock_duration);
    let max_lock = Uint128::from(max_lock_time);
    let multiplier_range = MAX_MULTIPLIER - MIN_MULTIPLIER;

    let additional = lock * multiplier_range / max_lock;
    MIN_MULTIPLIER + additional
}

pub fn calculate_weighted_amount(
    amount: Uint128,
    multiplier: Uint128,
) -> Uint128 {
    amount * multiplier / Uint128::new(1_000_000_000_000_000_000u128)
}

pub fn calculate_total_weighted_balance(
    positions: &[Position],
) -> Uint128 {
    positions.iter()
        .map(|p| p.weighted_amount)
        .fold(Uint128::zero(), |acc, x| acc + x)
}

5.4 veToken 核心实现

#[entry_point]
pub fn execute_ve(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: VeExecuteMsg,
) -> Result<Response, ContractError> {
    match msg {
        VeExecuteMsg::CreateLock { amount, lock_duration } => {
            execute_create_lock(deps, env, info, amount, lock_duration)
        }
        VeExecuteMsg::IncreaseAmount { position_id, amount } => {
            execute_increase_amount(deps, env, info, position_id, amount)
        }
        VeExecuteMsg::IncreaseDuration { position_id, new_duration } => {
            execute_increase_duration(deps, env, info, position_id, new_duration)
        }
        VeExecuteMsg::Withdraw { position_id } => {
            execute_withdraw_ve(deps, env, info, position_id)
        }
        VeExecuteMsg::ClaimRewards {} => {
            execute_claim_rewards_ve(deps, env, info)
        }
        VeExecuteMsg::NotifyRewardAmount { reward_rate } => {
            execute_notify_reward_amount_ve(deps, env, info, reward_rate)
        }
        _ => Err(ContractError::InvalidInput {}),
    }
}

fn execute_create_lock(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    amount: Uint128,
    lock_duration: u64,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;

    if lock_duration < config.min_lock_time {
        return Err(ContractError::LockTimeTooShort {});
    }
    if lock_duration > config.max_lock_time {
        return Err(ContractError::LockTimeTooLong {});
    }

    update_user_reward_ve(&mut *deps, &env, &info.sender)?;

    let multiplier = calculate_multiplier(lock_duration, config.max_lock_time);
    let weighted_amount = calculate_weighted_amount(amount, multiplier);

    let mut next_id = NEXT_POSITION_ID.load(deps.storage)?;
    next_id += 1;
    let position = Position {
        position_id: next_id,
        owner: info.sender.clone(),
        amount,
        start: env.block.time,
        end: env.block.time.plus_seconds(lock_duration),
        weighted_amount,
    };
    POSITIONS.save(deps.storage, next_id, &position)?;

    let mut user_pos = USER_POSITIONS
        .may_load(deps.storage, &info.sender)?
        .unwrap_or_default();
    user_pos.push(next_id);
    USER_POSITIONS.save(deps.storage, &info.sender, &user_pos)?;
    NEXT_POSITION_ID.save(deps.storage, &next_id)?;

    let mut state = REWARD_STATE.load(deps.storage)?;
    state.total_supply = checked_add(state.total_supply, weighted_amount)?;
    REWARD_STATE.save(deps.storage, &state)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::TransferFrom {
            owner: info.sender.to_string(),
            recipient: env.contract.address.to_string(),
            amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "create_lock")
        .add_attribute("position_id", next_id.to_string())
        .add_attribute("amount", amount)
        .add_attribute("lock_duration", lock_duration.to_string())
        .add_attribute("weighted_amount", weighted_amount))
}

fn execute_increase_amount(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    position_id: u64,
    amount: Uint128,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    let mut position = POSITIONS.load(deps.storage, position_id)?;

    if position.owner != info.sender {
        return Err(ContractError::Unauthorized {});
    }
    if env.block.time >= position.end {
        return Err(ContractError::LockExpired {});
    }

    update_user_reward_ve(&mut *deps, &env, &info.sender)?;

    let mut state = REWARD_STATE.load(deps.storage)?;
    state.total_supply = checked_sub(state.total_supply, position.weighted_amount)?;

    position.amount = checked_add(position.amount, amount)?;
    let remaining_lock = position.end.seconds() - env.block.time.seconds();
    let multiplier = calculate_multiplier(remaining_lock, config.max_lock_time);
    position.weighted_amount = calculate_weighted_amount(position.amount, multiplier);
    POSITIONS.save(deps.storage, position_id, &position)?;

    state.total_supply = checked_add(state.total_supply, position.weighted_amount)?;
    REWARD_STATE.save(deps.storage, &state)?;

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::TransferFrom {
            owner: info.sender.to_string(),
            recipient: env.contract.address.to_string(),
            amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "increase_amount")
        .add_attribute("position_id", position_id.to_string())
        .add_attribute("amount", amount)
        .add_attribute("new_weighted", position.weighted_amount))
}

fn execute_increase_duration(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    position_id: u64,
    new_duration: u64,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    let mut position = POSITIONS.load(deps.storage, position_id)?;

    if position.owner != info.sender {
        return Err(ContractError::Unauthorized {});
    }
    if env.block.time >= position.end {
        return Err(ContractError::LockExpired {});
    }

    let new_end = env.block.time.plus_seconds(new_duration);
    if new_end <= position.end {
        return Err(ContractError::CannotReduceLockTime {});
    }
    if new_duration > config.max_lock_time {
        return Err(ContractError::LockTimeTooLong {});
    }

    update_user_reward_ve(&mut *deps, &env, &info.sender)?;

    let mut state = REWARD_STATE.load(deps.storage)?;
    state.total_supply = checked_sub(state.total_supply, position.weighted_amount)?;

    position.end = new_end;
    let multiplier = calculate_multiplier(new_duration, config.max_lock_time);
    position.weighted_amount = calculate_weighted_amount(position.amount, multiplier);
    POSITIONS.save(deps.storage, position_id, &position)?;

    state.total_supply = checked_add(state.total_supply, position.weighted_amount)?;
    REWARD_STATE.save(deps.storage, &state)?;

    Ok(Response::new()
        .add_attribute("method", "increase_duration")
        .add_attribute("position_id", position_id.to_string())
        .add_attribute("new_end", position.end.to_string())
        .add_attribute("new_weighted", position.weighted_amount))
}

fn execute_withdraw_ve(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    position_id: u64,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    let position = POSITIONS.load(deps.storage, position_id)?;

    if position.owner != info.sender {
        return Err(ContractError::Unauthorized {});
    }
    if env.block.time < position.end {
        return Err(ContractError::LockNotExpired {});
    }

    update_user_reward_ve(&mut *deps, &env, &info.sender)?;

    let mut state = REWARD_STATE.load(deps.storage)?;
    state.total_supply = checked_sub(state.total_supply, position.weighted_amount)?;
    REWARD_STATE.save(deps.storage, &state)?;

    let mut user_pos = USER_POSITIONS
        .may_load(deps.storage, &info.sender)?
        .unwrap_or_default();
    user_pos.retain(|&id| id != position_id);
    USER_POSITIONS.save(deps.storage, &info.sender, &user_pos)?;

    POSITIONS.remove(deps.storage, position_id);

    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.staking_token.to_string(),
        msg: to_binary(&Cw20ExecuteMsg::Transfer {
            recipient: info.sender.to_string(),
            amount: position.amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("method", "withdraw")
        .add_attribute("position_id", position_id.to_string())
        .add_attribute("amount", position.amount))
}

fn update_reward_state_ve(
    deps: &mut DepsMut,
    env: &Env,
) -> Result<VeRewardState, ContractError> {
    let mut state = REWARD_STATE.load(deps.storage)?;

    let current_time = env.block.time;
    let time_elapsed = if current_time < state.period_finish {
        current_time.seconds() - state.last_update_time.seconds()
    } else {
        if state.last_update_time < state.period_finish {
            state.period_finish.seconds() - state.last_update_time.seconds()
        } else {
            0
        }
    };

    if !state.total_supply.is_zero() && time_elapsed > 0 {
        state.reward_per_token_stored = calculate_reward_per_token(
            state.reward_per_token_stored,
            state.reward_rate,
            state.total_supply,
            time_elapsed,
        );
    }

    if current_time >= state.period_finish {
        state.reward_rate = Uint128::zero();
    }

    state.last_update_time = current_time;
    REWARD_STATE.save(deps.storage, &state)?;
    Ok(state)
}

fn update_user_reward_ve(
    deps: &mut DepsMut,
    env: &Env,
    user: &Addr,
) -> Result<VeUserReward, ContractError> {
    let state = update_reward_state_ve(deps, env)?;

    let user_pos = USER_POSITIONS
        .may_load(deps.storage, user)?
        .unwrap_or_default();
    let mut total_weighted = Uint128::zero();
    for pos_id in &user_pos {
        if let Ok(pos) = POSITIONS.load(deps.storage, pos_id) {
            total_weighted = checked_add(total_weighted, pos.weighted_amount)?;
        }
    }

    let mut user_reward = USER_REWARDS
        .may_load(deps.storage, user)?
        .unwrap_or_default();

    user_reward.rewards = calculate_earned(
        total_weighted,
        state.reward_per_token_stored,
        user_reward.reward_per_token_paid,
        user_reward.rewards,
    )?;

    user_reward.reward_per_token_paid = state.reward_per_token_stored;
    USER_REWARDS.save(deps.storage, user, &user_reward)?;
    Ok(user_reward)
}

5.5 veToken 前端交互 (TypeScript)

import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";

const CHAIN_ID = "msg-chain-1";
const RPC_URL = "https://rpc.msgchain.org";

interface VePosition {
  position_id: number;
  owner: string;
  amount: string;
  start: string;
  end: string;
  weighted_amount: string;
}

export class VeTokenClient {
  private client: SigningCosmWasmClient;
  private wallet: DirectSecp256k1HdWallet;
  private contractAddress: string;

  constructor(
    client: SigningCosmWasmClient,
    wallet: DirectSecp256k1HdWallet,
    contractAddress: string,
  ) {
    this.client = client;
    this.wallet = wallet;
    this.contractAddress = contractAddress;
  }

  static async connect(
    mnemonic: string,
    contractAddress: string,
  ): Promise<VeTokenClient> {
    const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonic, {
      prefix: "msg",
    });
    const client = await SigningCosmWasmClient.connectWithSigner(
      RPC_URL,
      wallet,
    );
    return new VeTokenClient(client, wallet, contractAddress);
  }

  async createLock(amount: string, lockDays: number): Promise<string> {
    const lockSeconds = lockDays * 86400;
    const sender = (await this.wallet.getAccounts())[0].address;
    const tx = await this.client.execute(
      sender,
      this.contractAddress,
      {
        create_lock: { amount, lock_duration: lockSeconds },
      },
      "auto" as any,
    );
    return tx.transactionHash;
  }

  async getPositions(address: string): Promise<VePosition[]> {
    const posIds: number[] = await this.client.queryContractSmart(
      this.contractAddress,
      { user_positions: { address } },
    );
    const positions: VePosition[] = [];
    for (const id of posIds) {
      const pos: VePosition = await this.client.queryContractSmart(
        this.contractAddress,
        { position: { position_id: id } },
      );
      positions.push(pos);
    }
    return positions;
  }

  async getPendingRewards(address: string): Promise<string> {
    const result: { pending_rewards: string } =
      await this.client.queryContractSmart(
        this.contractAddress,
        { pending_rewards: { address } },
      );
    return result.pending_rewards;
  }

  async claimRewards(): Promise<string> {
    const sender = (await this.wallet.getAccounts())[0].address;
    const tx = await this.client.execute(
      sender,
      this.contractAddress,
      { claim_rewards: {} },
      "auto" as any,
    );
    return tx.transactionHash;
  }

  async getEffectiveMultiplier(address: string): Promise<string> {
    const result: string = await this.client.queryContractSmart(
      this.contractAddress,
      { reward_multiplier: { address } },
    );
    return result;
  }
}

6. 前端实现

6.1 项目结构

frontend/
|-- src/
|   |-- components/
|   |   |-- StakeDashboard.tsx
|   |   |-- StakeForm.tsx
|   |   |-- RewardClaimPanel.tsx
|   |   |-- PoolList.tsx
|   |   |-- PoolCard.tsx
|   |   |-- VeLockManager.tsx
|   |   |-- APRDisplay.tsx
|   |   |-- TransactionStatus.tsx
|   |   |-- WalletConnect.tsx
|   |-- hooks/
|   |   |-- useStakingRewards.ts
|   |   |-- useWallet.ts
|   |   |-- useTransaction.ts
|   |-- utils/
|   |   |-- contract.ts
|   |   |-- format.ts
|   |   |-- math.ts
|   |-- types/
|   |   |-- index.ts
|   |-- App.tsx
|   |-- index.tsx
|-- package.json
|-- tsconfig.json

6.2 类型定义

// types/index.ts

export interface Config {
  staking_token: string;
  rewards_token: string;
  rewards_distributor: string;
  duration: number;
  staking_token_decimals: number;
  rewards_token_decimals: number;
}

export interface RewardState {
  reward_rate: string;
  period_finish: string;
  last_update_time: string;
  reward_per_token_stored: string;
  total_supply: string;
}

export interface UserInfo {
  address: string;
  staked_amount: string;
  pending_rewards: string;
  reward_per_token_paid: string;
  rewards: string;
}

export interface PoolInfo {
  pool_id: number;
  staking_token: string;
  alloc_point: string;
  reward_per_token_stored: string;
  total_supply: string;
  last_update_time: string;
  active: boolean;
}

export interface VePosition {
  position_id: number;
  owner: string;
  amount: string;
  start: string;
  end: string;
  weighted_amount: string;
}

export enum TxStatus {
  PENDING = "pending",
  SUCCESS = "success",
  FAILED = "failed",
}

6.3 合约交互层

// utils/contract.ts

import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";

export class StakingRewardsContract {
  private client: SigningCosmWasmClient | null = null;
  private sender: string = "";

  constructor(public readonly contractAddress: string) {}

  setClient(client: SigningCosmWasmClient, sender: string) {
    this.client = client;
    this.sender = sender;
  }

  async queryConfig() {
    return this.client!.queryContractSmart(this.contractAddress, { config: {} });
  }

  async queryRewardState() {
    return this.client!.queryContractSmart(this.contractAddress, { reward_state: {} });
  }

  async queryStakedBalance(address: string) {
    return this.client!.queryContractSmart(this.contractAddress, {
      staked_balance: { address },
    });
  }

  async queryPendingRewards(address: string) {
    return this.client!.queryContractSmart(this.contractAddress, {
      pending_rewards: { address },
    });
  }

  async queryUserInfo(address: string) {
    return this.client!.queryContractSmart(this.contractAddress, {
      user_info: { address },
    });
  }

  async queryAPR() {
    return this.client!.queryContractSmart(this.contractAddress, { apr: {} });
  }

  async stake(amount: string): Promise<string> {
    const tx = await this.client!.execute(
      this.sender,
      this.contractAddress,
      { stake: { amount } },
      "auto" as any,
    );
    return tx.transactionHash;
  }

  async withdraw(amount: string): Promise<string> {
    const tx = await this.client!.execute(
      this.sender,
      this.contractAddress,
      { withdraw: { amount } },
      "auto" as any,
    );
    return tx.transactionHash;
  }

  async claimRewards(): Promise<string> {
    const tx = await this.client!.execute(
      this.sender,
      this.contractAddress,
      { claim_rewards: {} },
      "auto" as any,
    );
    return tx.transactionHash;
  }
}

6.4 格式化工具

// utils/format.ts

import Big from "big.js";

const DECIMALS = 18;

export function fromAtto(value: string, decimals: number = DECIMALS): string {
  try {
    const divisor = Big(10).pow(decimals);
    return Big(value).div(divisor).toFixed(4);
  } catch {
    return "0.0000";
  }
}

export function toAtto(value: string, decimals: number = DECIMALS): string {
  try {
    const multiplier = Big(10).pow(decimals);
    return Big(value).mul(multiplier).toFixed(0);
  } catch {
    return "0";
  }
}

export function formatAPR(apr: string): string {
  return `${Number(apr).toFixed(2)}%`;
}

export function formatRemainingTime(endTimestamp: string): string {
  const now = Math.floor(Date.now() / 1000);
  const end = Math.floor(new Date(endTimestamp).getTime() / 1000);
  const diff = Math.max(0, end - now);
  const days = Math.floor(diff / 86400);
  const hours = Math.floor((diff % 86400) / 3600);
  const minutes = Math.floor((diff % 3600) / 60);
  if (days > 0) return `${days}d ${hours}h`;
  if (hours > 0) return `${hours}h ${minutes}m`;
  return `${minutes}m`;
}

export function truncateAddress(address: string): string {
  if (address.length <= 12) return address;
  return `${address.slice(0, 6)}...${address.slice(-6)}`;
}

export function formatAmount(value: string): string {
  const num = Big(value);
  if (num.gte(Big(10).pow(15))) return num.div(Big(10).pow(15)).toFixed(2) + "Q";
  if (num.gte(Big(10).pow(12))) return num.div(Big(10).pow(12)).toFixed(2) + "T";
  if (num.gte(Big(10).pow(9))) return num.div(Big(10).pow(9)).toFixed(2) + "B";
  if (num.gte(Big(10).pow(6))) return num.div(Big(10).pow(6)).toFixed(2) + "M";
  if (num.gte(1000)) return num.div(1000).toFixed(2) + "K";
  return num.toFixed(2);
}

6.5 React 组件

// components/WalletConnect.tsx

import React, { useState } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";

const CHAIN_ID = "msg-chain-1";
const RPC_URL = "https://rpc.msgchain.org";

interface WalletConnectProps {
  onWalletConnected: (client: SigningCosmWasmClient, address: string) => void;
}

export const WalletConnect: React.FC<WalletConnectProps> = ({
  onWalletConnected,
}) => {
  const [connecting, setConnecting] = useState(false);
  const [address, setAddress] = useState<string | null>(null);

  const connectKeplr = async () => {
    if (!window.keplr) {
      alert("请安装 Keplr 钱包");
      return;
    }
    try {
      setConnecting(true);
      await window.keplr.enable(CHAIN_ID);
      const offlineSigner = window.keplr.getOfflineSigner(CHAIN_ID);
      const accounts = await offlineSigner.getAccounts();
      const client = await SigningCosmWasmClient.connectWithSigner(
        RPC_URL,
        offlineSigner,
      );
      setAddress(accounts[0].address);
      onWalletConnected(client, accounts[0].address);
    } catch (err) {
      console.error("钱包连接失败:", err);
    } finally {
      setConnecting(false);
    }
  };

  return (
    <div className="wallet-connect">
      {address ? (
        <div className="wallet-info">
          <span className="address">
            {address.slice(0, 6)}...{address.slice(-4)}
          </span>
        </div>
      ) : (
        <button onClick={connectKeplr} disabled={connecting}>
          {connecting ? "连接中..." : "连接钱包"}
        </button>
      )}
    </div>
  );
};
// components/StakeDashboard.tsx

import React, { useEffect, useState, useCallback } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { StakingRewardsContract } from "../utils/contract";
import { fromAtto, formatAPR } from "../utils/format";
import { StakeForm } from "./StakeForm";
import { RewardClaimPanel } from "./RewardClaimPanel";
import { APRDisplay } from "./APRDisplay";
import type { Config, UserInfo, RewardState } from "../types";

interface StakeDashboardProps {
  client: SigningCosmWasmClient;
  address: string;
  contractAddress: string;
}

export const StakeDashboard: React.FC<StakeDashboardProps> = ({
  client,
  address,
  contractAddress,
}) => {
  const contract = new StakingRewardsContract(contractAddress);
  contract.setClient(client, address);

  const [config, setConfig] = useState<Config | null>(null);
  const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
  const [rewardState, setRewardState] = useState<RewardState | null>(null);
  const [apr, setApr] = useState<string>("0");
  const [loading, setLoading] = useState(true);

  const refreshData = useCallback(async () => {
    try {
      const [cfg, user, state, aprData] = await Promise.all([
        contract.queryConfig(),
        contract.queryUserInfo(address),
        contract.queryRewardState(),
        contract.queryAPR(),
      ]);
      setConfig(cfg);
      setUserInfo(user);
      setRewardState(state);
      setApr(aprData.apr);
    } catch (err) {
      console.error("数据查询失败:", err);
    } finally {
      setLoading(false);
    }
  }, [contract, address]);

  useEffect(() => {
    refreshData();
    const interval = setInterval(refreshData, 15000);
    return () => clearInterval(interval);
  }, [refreshData]);

  if (loading) return <div className="loading">加载中...</div>;

  return (
    <div className="stake-dashboard">
      <div className="dashboard-header">
        <h2>收益农场</h2>
        <APRDisplay apr={apr} />
      </div>

      <div className="stats-grid">
        <div className="stat-card">
          <div className="stat-label">总质押量</div>
          <div className="stat-value">
            {rewardState ? fromAtto(rewardState.total_supply) : "0"} STK
          </div>
        </div>
        <div className="stat-card">
          <div className="stat-label">我的质押</div>
          <div className="stat-value">
            {userInfo ? fromAtto(userInfo.staked_amount) : "0"} STK
          </div>
        </div>
        <div className="stat-card">
          <div className="stat-label">待领取奖励</div>
          <div className="stat-value highlight">
            {userInfo ? fromAtto(userInfo.pending_rewards) : "0"} REW
          </div>
        </div>
        <div className="stat-card">
          <div className="stat-label">奖励速率</div>
          <div className="stat-value">
            {rewardState ? fromAtto(rewardState.reward_rate) : "0"} REW/s
          </div>
        </div>
      </div>

      <div className="dashboard-actions">
        <StakeForm contract={contract} onSuccess={refreshData} />
        <RewardClaimPanel
          contract={contract}
          pendingRewards={userInfo?.pending_rewards || "0"}
          onClaimSuccess={refreshData}
        />
      </div>
    </div>
  );
};
// components/StakeForm.tsx

import React, { useState } from "react";
import { StakingRewardsContract } from "../utils/contract";
import { toAtto } from "../utils/format";

interface StakeFormProps {
  contract: StakingRewardsContract;
  onSuccess: () => void;
}

export const StakeForm: React.FC<StakeFormProps> = ({
  contract,
  onSuccess,
}) => {
  const [amount, setAmount] = useState("");
  const [action, setAction] = useState<"stake" | "withdraw">("stake");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!amount || Number(amount) <= 0) {
      setError("请输入有效数量");
      return;
    }
    setLoading(true);
    setError("");
    try {
      const atomAmount = toAtto(amount);
      if (action === "stake") {
        await contract.stake(atomAmount);
      } else {
        await contract.withdraw(atomAmount);
      }
      setAmount("");
      onSuccess();
    } catch (err: any) {
      setError(err.message || "交易失败");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="stake-form-container">
      <div className="action-tabs">
        <button
          className={`tab ${action === "stake" ? "active" : ""}`}
          onClick={() => setAction("stake")}
        >
          质押
        </button>
        <button
          className={`tab ${action === "withdraw" ? "active" : ""}`}
          onClick={() => setAction("withdraw")}
        >
          取出
        </button>
      </div>

      <form onSubmit={handleSubmit} className="stake-form">
        <div className="input-group">
          <label>{action === "stake" ? "质押数量" : "取出数量"}</label>
          <div className="input-wrapper">
            <input
              type="number"
              step="0.0001"
              min="0"
              value={amount}
              onChange={(e) => setAmount(e.target.value)}
              placeholder="0.00"
              disabled={loading}
            />
            <span className="token-symbol">STK</span>
          </div>
        </div>
        {error && <div className="error-message">{error}</div>}
        <button type="submit" disabled={loading} className="submit-btn">
          {loading ? "处理中..." : action === "stake" ? "质押" : "取出"}
        </button>
      </form>
    </div>
  );
};
// components/RewardClaimPanel.tsx

import React, { useState } from "react";
import { StakingRewardsContract } from "../utils/contract";
import { fromAtto } from "../utils/format";

interface RewardClaimPanelProps {
  contract: StakingRewardsContract;
  pendingRewards: string;
  onClaimSuccess: () => void;
}

export const RewardClaimPanel: React.FC<RewardClaimPanelProps> = ({
  contract,
  pendingRewards,
  onClaimSuccess,
}) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const hasRewards = Number(pendingRewards) > 0;

  const handleClaim = async () => {
    setLoading(true);
    setError("");
    try {
      await contract.claimRewards();
      onClaimSuccess();
    } catch (err: any) {
      setError(err.message || "领取失败");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="reward-claim-panel">
      <h3>奖励</h3>
      <div className="reward-amount">
        <span className="amount">{fromAtto(pendingRewards)}</span>
        <span className="token">REW</span>
      </div>
      <button
        onClick={handleClaim}
        disabled={!hasRewards || loading}
        className="claim-btn"
      >
        {loading ? "领取中..." : hasRewards ? "领取奖励" : "无奖励可领"}
      </button>
      {error && <div className="error-message">{error}</div>}
    </div>
  );
};
// components/APRDisplay.tsx

import React from "react";
import { formatAPR } from "../utils/format";

interface APRDisplayProps {
  apr: string;
}

export const APRDisplay: React.FC<APRDisplayProps> = ({ apr }) => {
  const aprValue = Number(apr);
  return (
    <div className={`apr-display ${aprValue > 0 ? "active" : "inactive"}`}>
      <span className="apr-label">APR</span>
      <span className="apr-value">{formatAPR(apr)}</span>
    </div>
  );
};
// components/PoolCard.tsx

import React from "react";
import { fromAtto } from "../utils/format";
import type { PoolInfo } from "../types";

interface PoolCardProps {
  pool: PoolInfo;
  onDeposit: () => void;
  onWithdraw: () => void;
  userStaked: string;
  userPendingRewards: string;
}

export const PoolCard: React.FC<PoolCardProps> = ({
  pool,
  onDeposit,
  onWithdraw,
  userStaked,
  userPendingRewards,
}) => {
  return (
    <div className={`pool-card ${pool.active ? "" : "inactive"}`}>
      <div className="pool-header">
        <h3>Pool #{pool.pool_id}</h3>
        <span className={`status ${pool.active ? "active" : "paused"}`}>
          {pool.active ? "运行中" : "已暂停"}
        </span>
      </div>
      <div className="pool-details">
        <div className="detail-row">
          <span>质押代币</span>
          <span className="mono">{pool.staking_token.slice(0, 10)}...</span>
        </div>
        <div className="detail-row">
          <span>总质押</span>
          <span>{fromAtto(pool.total_supply)}</span>
        </div>
        <div className="detail-row">
          <span>我的质押</span>
          <span>{fromAtto(userStaked)}</span>
        </div>
        <div className="detail-row">
          <span>待领取奖励</span>
          <span className="rewards">{fromAtto(userPendingRewards)}</span>
        </div>
      </div>
      <div className="pool-actions">
        <button onClick={onDeposit} className="btn-deposit">存入</button>
        <button onClick={onWithdraw} className="btn-withdraw">取出</button>
      </div>
    </div>
  );
};

6.6 App 主入口

// App.tsx

import React, { useState } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { WalletConnect } from "./components/WalletConnect";
import { StakeDashboard } from "./components/StakeDashboard";

const STAKING_REWARDS_ADDRESS = "msg1...";

function App() {
  const [client, setClient] = useState<SigningCosmWasmClient | null>(null);
  const [address, setAddress] = useState<string>("");

  const handleWalletConnected = (
    c: SigningCosmWasmClient,
    addr: string,
  ) => {
    setClient(c);
    setAddress(addr);
  };

  return (
    <div className="app">
      <header>
        <h1>MSG Chain 收益农场</h1>
        <WalletConnect onWalletConnected={handleWalletConnected} />
      </header>
      <main>
        {client && address ? (
          <StakeDashboard
            client={client}
            address={address}
            contractAddress={STAKING_REWARDS_ADDRESS}
          />
        ) : (
          <div className="connect-prompt">请先连接钱包</div>
        )}
      </main>
    </div>
  );
}

export default App;

7. 安全考虑

7.1 奖励计算精度

在使用 Uint128 进行奖励计算时,精度损失是最大的问题。本文使用 1e18 缩放因子来解决:

// 不精确的做法(精度损失):
// reward_per_token_stored += reward_rate * time_elapsed / total_supply
// 当 total_supply 很大时, 结果会被截断为 0

// 精确的做法:
// reward_per_token_stored += reward_rate * time_elapsed * PRECISION / total_supply

即使使用 1e18 缩放,仍需注意:

  1. Uint128 最大值为约 3.4e38,足够大多数场景
  2. 极端情况下要考虑溢出:reward_rate * time_elapsed * PRECISION 不应超过 Uint128::MAX
  3. MSG Chain 精度为 18 位,与 PRECISION 对齐
// 最大安全计算校验
pub fn validate_safe_reward_rate(
    reward_rate: Uint128,
    duration: u64,
    total_supply: Uint128,
) -> Result<(), ContractError> {
    // reward_rate * duration * PRECISION 不应溢出
    let max_reward = reward_rate
        .checked_mul(Uint128::from(duration))?
        .checked_mul(Uint128::new(1_000_000_000_000_000_000u128))?;

    // max_reward / total_supply 应合理
    if !total_supply.is_zero() {
        let _ = max_reward.checked_div(total_supply)?;
    }
    Ok(())
}

7.2 溢出保护

使用 checked 算术操作:

// 错误: 可能静默溢出
let total = a + b;
let diff = a - b;

// 正确: 使用 checked 操作
let total = a.checked_add(b).ok_or(ContractError::Overflow {})?;
let diff = a.checked_sub(b).ok_or(ContractError::Overflow {})?;

7.3 奖励操纵防护

攻击场景 1: 闪电贷攻击

攻击者通过闪电贷借入大量质押代币,瞬间存入再取出,稀释其他用户的奖励份额。

防护措施:

// 在 update_reward_state 中, 无质押量期间不累积奖励
if !state.total_supply.is_zero() && time_elapsed > 0 {
    state.reward_per_token_stored = calculate_reward_per_token(...);
}
// total_supply = 0 时, reward_per_token_stored 不增加

攻击场景 2: 频繁存取以操纵 reward_per_token

当攻击者在奖励将发放前存入大量代币,领取后再提出,可窃取其他用户的奖励。

防护措施:

// 任何余额变更前必须更新奖励
// Stake 和 Withdraw 必须先调用 update_user_reward
// 确保 reward_per_token_paid 始终在奖励计算后才更新

7.4 紧急提取机制

当合约出现严重漏洞时,管理员可调用 EmergencyWithdraw 将所有质押代币提取到安全地址。

// 紧急提取时不清算奖励,只归还本金
// 用户在安全恢复后可再通过 ClaimRewards 领取累积奖励

7.5 常见漏洞清单

漏洞类型 风险 防护
重入攻击 在转账回调中再次调用 stake/withdraw 遵循 CEI 模式 (Checks-Effects-Interactions)
整数溢出 奖励计算错误 使用 checked math
权限漏洞 非授权者调用管理员函数 严格的 sender 校验
精度损失 小额奖励丢失 1e18 缩放因子
奖励周期覆盖 管理员在周期中覆盖导致奖励丢失 不允许覆盖进行中的周期
未初始化的池 权重为 0 的池异常 验证池已激活且 alloc_point > 0

7.6 审计与测试建议

// 属性测试 (使用 proptest)
#[cfg(test)]
mod proptests {
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn test_calculate_earned_never_overflows(
            balance in 0..10_000_000_000u128,
            stored in 0..10_000_000_000_000_000_000_000_000u128,
            paid in 0..10_000_000_000_000_000_000_000_000u128,
            rewards in 0..10_000_000_000u128,
        ) {
            let result = calculate_earned(
                Uint128::new(balance),
                Uint128::new(stored),
                Uint128::new(paid),
                Uint128::new(rewards),
            );
            // 只要 paid > stored, 应该返回 Overflow 错误
            if paid > stored {
                assert!(result.is_err());
            } else {
                assert!(result.is_ok());
            }
        }
    }
}

测试覆盖率要求:


8. 完整示例

8.1 端到端部署流程

# Step 1: 构建合约
cd contracts/staking-rewards
cargo wasm

# Step 2: 优化 WASM
docker run --rm -v "$(pwd)":/code \
  --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \
  cosmwasm/optimizer:0.15.0

# Step 3: 上传合约到 MSG Chain
msgd tx wasm store artifacts/staking_rewards.wasm \
  --from deployer \
  --chain-id msg-chain-1 \
  --gas auto --gas-prices 1000000000attoMSG \
  --node https://rpc.msgchain.org:443 \
  -y

# Step 4: 实例化合约
CODE_ID=1
INIT='{
  "staking_token": "msg1stakingtoken...",
  "rewards_token": "msg1rewardstoken...",
  "rewards_distributor": "msg1deployer...",
  "duration": 604800
}'

msgd tx wasm instantiate $CODE_ID "$INIT" \
  --from deployer \
  --label "MSGChain_StakingRewards_v1" \
  --no-admin \
  --chain-id msg-chain-1 \
  --gas auto --gas-prices 1000000000attoMSG \
  --node https://rpc.msgchain.org:443 \
  -y

8.2 设置奖励速率

# 设置每秒奖励 1000 REW
REWARD_RATE="1000000000000000000000"  # 1000 * 10^18

msgd tx wasm execute msg1contractaddress... \
  '{"notify_reward_amount":{"reward_rate":"1000000000000000000000"}}' \
  --from distributor \
  --gas auto --gas-prices 1000000000attoMSG \
  --chain-id msg-chain-1 \
  --node https://rpc.msgchain.org:443 \
  -y

8.3 用户质押

# Step 1: 先授权 StakingRewards 合约使用用户的 STK 代币
msgd tx wasm execute msg1stktoken... \
  '{"increase_allowance":{"spender":"msg1contractaddress...","amount":"100000000000000000000000"}}' \
  --from user \
  --gas auto --gas-prices 1000000000attoMSG \
  -y

# Step 2: 质押 1000 STK
STAKE_AMOUNT="1000000000000000000000"  # 1000 * 10^18

msgd tx wasm execute msg1contractaddress... \
  '{"stake":{"amount":"1000000000000000000000"}}' \
  --from user \
  --gas auto --gas-prices 1000000000attoMSG \
  -y

8.4 查询待领取奖励

msgd query wasm contract-state smart msg1contractaddress... \
  '{"pending_rewards":{"address":"msg1user..."}}'

预期返回:

{
  "data": {
    "address": "msg1user...",
    "pending_rewards": "5000000000000000000"
  }
}

8.5 领取奖励

msgd tx wasm execute msg1contractaddress... \
  '{"claim_rewards":{}}' \
  --from user \
  --gas auto --gas-prices 1000000000attoMSG \
  -y

8.6 取出质押代币

WITHDRAW_AMOUNT="500000000000000000000"  # 500 STK

msgd tx wasm execute msg1contractaddress... \
  '{"withdraw":{"amount":"500000000000000000000"}}' \
  --from user \
  --gas auto --gas-prices 1000000000attoMSG \
  -y

8.7 完整集成测试 (Rust)

use cosmwasm_std::{Addr, Uint128};
use cw_multi_test::{App, ContractWrapper, Executor};
use msgchain_staking_rewards::msg::{
    ExecuteMsg, InstantiateMsg, QueryMsg, PendingRewardsResponse,
    UserInfoResponse, StakedBalanceResponse,
};

#[test]
fn test_e2e_staking_flow() {
    let mut app = App::default();
    let deployer = Addr::unchecked("deployer");
    let user = Addr::unchecked("user");

    // 部署 CW20 代币
    let cw20_id = app.store_code(ContractWrapper::new(
        cw20_base::entry::execute,
        cw20_base::entry::instantiate,
        cw20_base::entry::query,
    ));

    let stk = app.instantiate_contract(
        cw20_id,
        deployer.clone(),
        &cw20_base::msg::InstantiateMsg {
            name: "Test STK".to_string(),
            symbol: "TSTK".to_string(),
            decimals: 18,
            initial_balances: vec![
                cw20::Cw20Coin {
                    address: user.to_string(),
                    amount: Uint128::new(1_000_000_000_000_000_000_000u128), // 1M
                },
            ],
            mint: None,
            marketing: None,
        },
        &[],
        "stk",
        None,
    ).unwrap();

    let rew = app.instantiate_contract(
        cw20_id,
        deployer.clone(),
        &cw20_base::msg::InstantiateMsg {
            name: "Test REW".to_string(),
            symbol: "TREW".to_string(),
            decimals: 18,
            initial_balances: vec![
                cw20::Cw20Coin {
                    address: deployer.to_string(),
                    amount: Uint128::new(10_000_000_000_000_000_000_000u128), // 10M
                },
            ],
            mint: None,
            marketing: None,
        },
        &[],
        "rew",
        None,
    ).unwrap();

    // 部署 StakingRewards
    let contract_id = app.store_code(ContractWrapper::new(
        msgchain_staking_rewards::contract::execute,
        msgchain_staking_rewards::contract::instantiate,
        msgchain_staking_rewards::contract::query,
    ));

    let staking = app.instantiate_contract(
        contract_id,
        deployer.clone(),
        &InstantiateMsg {
            staking_token: stk.to_string(),
            rewards_token: rew.to_string(),
            rewards_distributor: deployer.to_string(),
            duration: 86400, // 1 天
        },
        &[],
        "staking",
        None,
    ).unwrap();

    // 用户授权
    app.execute_contract(
        user.clone(),
        stk.clone(),
        &cw20_base::msg::ExecuteMsg::IncreaseAllowance {
            spender: staking.to_string(),
            amount: Uint128::new(1_000_000_000_000_000_000_000u128),
            expires: None,
        },
        &[],
    ).unwrap();

    // 设置奖励速率: 10 REW/s
    app.execute_contract(
        deployer.clone(),
        staking.clone(),
        &ExecuteMsg::NotifyRewardAmount {
            reward_rate: Uint128::new(10_000_000_000_000_000_000u128), // 10 REW/s
        },
        &[],
    ).unwrap();

    // 质押
    app.execute_contract(
        user.clone(),
        staking.clone(),
        &ExecuteMsg::Stake {
            amount: Uint128::new(500_000_000_000_000_000_000u128), // 500 STK
        },
        &[],
    ).unwrap();

    // 快进 100 秒
    app.update_block(|b| {
        b.time = b.time.plus_seconds(100);
        b.height += 20;
    });

    // 验证待领取奖励: 10 * 100 = 1000 REW
    let pending: PendingRewardsResponse = app.wrap().query_wasm_smart(
        staking.clone(),
        &QueryMsg::PendingRewards {
            address: user.to_string(),
        },
    ).unwrap();
    assert_eq!(
        pending.pending_rewards.u128(),
        1000_000000000000000000u128 // 1000 REW in atto
    );

    // 领取奖励
    app.execute_contract(
        user.clone(),
        staking.clone(),
        &ExecuteMsg::ClaimRewards {},
        &[],
    ).unwrap();

    // 确认奖励已领取
    let user_info: UserInfoResponse = app.wrap().query_wasm_smart(
        staking.clone(),
        &QueryMsg::UserInfo {
            address: user.to_string(),
        },
    ).unwrap();
    assert_eq!(user_info.rewards, Uint128::zero());

    // 取出部分质押
    app.execute_contract(
        user.clone(),
        staking.clone(),
        &ExecuteMsg::Withdraw {
            amount: Uint128::new(250_000_000_000_000_000_000u128),
        },
        &[],
    ).unwrap();

    // 验证余额
    let balance: StakedBalanceResponse = app.wrap().query_wasm_smart(
        staking.clone(),
        &QueryMsg::StakedBalance {
            address: user.to_string(),
        },
    ).unwrap();
    assert_eq!(
        balance.balance,
        Uint128::new(250_000_000_000_000_000_000u128)
    );
}

8.8 Python 交互脚本

#!/usr/bin/env python3
"""完整的用户交互流程: 质押 - 领取 - 取出"""

import json
import httpx

RPC = "https://rpc.msgchain.org"
REST = "https://rest.msgchain.org"
CHAIN_ID = "msg-chain-1"
GAS_PRICE = "1000000000attoMSG"

CONTRACT_ADDR = "msg1..."
STAKING_TOKEN = "msg1stk..."
WALLET_ADDR = "msg1user..."


def query_smart(contract: str, msg: dict) -> dict:
    resp = httpx.get(
        f"{REST}/cosmwasm/wasm/v1/contract/{contract}/smart",
        params={"msg": json.dumps(msg).encode().hex()},
    )
    return resp.json()


def query_pending_rewards(address: str) -> str:
    result = query_smart(CONTRACT_ADDR, {
        "pending_rewards": {"address": address}
    })
    return result["data"]["pending_rewards"]


def display_user_info(address: str):
    result = query_smart(CONTRACT_ADDR, {
        "user_info": {"address": address}
    })
    data = result["data"]
    staked = int(data["staked_amount"]) / 1e18
    pending = int(data["pending_rewards"]) / 1e18
    print(f"地址: {address}")
    print(f"质押量: {staked:.4f} STK")
    print(f"待领取奖励: {pending:.4f} REW")


def main():
    print("=== MSG Chain 收益农场交互示例 ===\n")

    # 查询配置
    config = query_smart(CONTRACT_ADDR, {"config": {}})
    print(f"质押代币: {config['data']['staking_token']}")
    print(f"奖励代币: {config['data']['rewards_token']}")
    print()

    # 查询用户信息
    display_user_info(WALLET_ADDR)

    # 查询 APR
    apr = query_smart(CONTRACT_ADDR, {"apr": {}})
    print(f"\n当前 APR: {apr['data']['apr']}%")


if __name__ == "__main__":
    main()

附录

A. 部署环境核对清单

B. MSG Chain 网络参数

参数 值
Chain ID msg-chain-1
Bech32 前缀 msg
原生代币 umsg (1 MSG = 10^18 umsg)
精度 18
出块时间 ~5 秒
日均区块数 ~17,280
Gas 价格 1,000,000,000 attoMSG/gas
RPC 端点 https://rpc.msgchain.org
REST 端点 https://rest.msgchain.org

C. 常用 msgd 命令速查

# 查询合约状态
msgd query wasm contract-state smart <contract> '<query>'

# 查询合约余额
msgd query wasm contract-state all <contract>

# 列出合约历史
msgd query wasm contract-history <contract>

# 模拟执行
msgd tx wasm execute <contract> '<msg>' --dry-run

# 获取交易详情
msgd query tx <tx_hash>

D. 参考资源


本文档面向 MSG Chain 开发者,完整覆盖从基础 StakingRewards 合约到多资产农场、veToken 锁仓模型的全套实现。所有代码均为 Rust + CosmWasm 框架,可直接编译部署到 msg-chain-1 网络。


本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/