MSG Chain 收益农场与 Staking Rewards 实现指南
适用链:
msg-chain-1
Bech32 前缀:msg
精度: 18 decimals
Gas 价格: 1,000,000,000 attoMSG/gas
出块时间: ~5 秒
主网状态: No-Go
目录
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]
这是一个经典的"快照式"会计模型:
reward_per_token_stored全局累积,记录到当前时刻为止,每个质押单位应得的奖励。user_reward_per_token_paid[user]记录用户最后一次操作时的快照。- 用户已赚取但未领取的奖励存于
rewards[user]。
这种设计的优点:
- 用户无需手动触发奖励计算,任何交互(存款、取款、领取)都会自动更新。
- 全局变量消耗固定存储,不随用户数增长。
- 精度由
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 缩放,仍需注意:
- Uint128 最大值为约 3.4e38,足够大多数场景
- 极端情况下要考虑溢出:
reward_rate * time_elapsed * PRECISION不应超过 Uint128::MAX - 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. 部署环境核对清单
- [ ]
msgdCLI 已安装并可正常使用 - [ ] 钱包地址前缀为
msg - [ ] 已获取测试网代币(faucet 或从官方渠道)
- [ ] Rust 工具链已安装
rustup target add wasm32-unknown-unknown - [ ]
docker已安装(用于 WASM 优化) - [ ] StakingRewards 合约源码已编译通过并测试通过
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. 参考资源
- CosmWasm 文档: https://docs.cosmwasm.com
- CW20 规范: https://github.com/CosmWasm/cw-plus
- SushiSwap MasterChef: https://github.com/sushiswap/sushiswap
- Curve veToken 模型: https://curve.readthedocs.io
本文档面向 MSG Chain 开发者,完整覆盖从基础 StakingRewards 合约到多资产农场、veToken 锁仓模型的全套实现。所有代码均为 Rust + CosmWasm 框架,可直接编译部署到 msg-chain-1 网络。
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
