MSG Chain 借贷协议完整实现指南
基于 CosmWasm 1.5 的 Compound/Aave 风格超额抵押借贷协议
适用链: MSG Chain (Chain ID:
msg-chain-1)
原生代币: MSG (decimals: 18, CoinType: 118, Bech32:msg)
主网状态: No-Go
目录
- 架构概述
- Part 1: 借贷池合约 (Lending Pool Contract)
- Part 2: 利率模型 (Interest Rate Model)
- Part 3: 预言机集成 (Oracle Integration)
- Part 4: 清算机器人 (Liquidation Bot)
- Part 5: 前端界面 (Frontend)
- Part 6: 测试 (Tests)
- 部署与运维
1. 架构概述
1.1 协议设计理念
本借贷协议参考 Compound v2 和 Aave V2 的核心设计,结合 MSG Chain 的 CosmWasm 1.5 运行环境实现。核心机制为超额抵押借贷——借款人必须存入价值高于借款金额的抵押品,以保护贷方资金安全。
┌──────────────────────────────────┐
│ 借贷池 (Lending Pool) │
│ ┌────────────────────────────┐ │
│ │ 抵押品池 (Collateral Pool) │ │
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ 流动资金池 (Liquidity Pool) │ │
│ └────────────────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ 准备金池 (Reserve Pool) │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ 预言机 │ │ 利率模型 │ │ 清算机器人│
│ (Oracle) │ │ (IRM) │ │ (Liquid.)│
└─────────┘ └─────────┘ └─────────┘
1.2 核心概念
| 概念 | 说明 | 类似Compound |
|---|---|---|
| 抵押因子 (Collateral Factor) | 抵押品可借出的最大比例,如 75% | collateralFactor |
| 清算阈值 (Liquidation Threshold) | 触发清算的健康因子阈值,如 80% | liquidationThreshold |
| 清算奖金 (Liquidation Bonus) | 清算人获得的额外抵押品比例,如 5% | liquidationIncentive |
| 准备金因子 (Reserve Factor) | 利息收入中留存协议的比例,如 10% | reserveFactor |
| 健康因子 (Health Factor) | 总抵押品价值 × 抵押因子 / 总借款 |
HF ≥ 1 为安全 |
1.3 合约架构
Contract: lending_pool.wasm
├── Instantiate: 初始化池参数
├── Execute:
│ ├── DepositCollateral 存入抵押品
│ ├── WithdrawCollateral 提取抵押品
│ ├── Borrow 借款
│ ├── RepayBorrow 还款
│ ├── Liquidate 清算
│ └── UpdateConfig 管理员更新配置
└── Query:
├── GetUserInfo 查询用户信息
├── GetPoolState 查询池状态
├── GetInterestRate 查询当前利率
└── GetLiquidationStatus 查询清算状态
1.4 MSG Chain 专有配置
chain_id: "msg-chain-1"
bech32_prefix: "msg"
coin_type: 118
native_token:
denom: "umsg"
decimals: 18
gas_limits:
low: 0.01 umsg/gas
average: 1,000,000,000 attoMSG/gas
high: 0.04 umsg/gas
block_time: 5s
consensus: "Round-Robin + DAR (Dynamic Availability Ranking)"
cosmwasm_version: "1.5"
1.5 协议工作流程
用户存入抵押品 (Deposit)
│
▼
更新用户抵押品余额 ◄──── 预言机价格
│
▼
用户借款 (Borrow)
│
▼
检查健康因子 HF ≥ 1? ──否──> 拒绝借款
│是
▼
更新用户借款余额
更新池状态
更新利率
│
▼
市场价格波动 ◄──── 预言机持续更新
│
▼
健康因子低于 1? ──是──> 可被清算
│否 │
▼ ▼
正常运行 清算人调用 Liquidate
│
▼
偿还借款,获得抵押品 (+ 清算奖励)
2. Part 1: 借贷池合约 (Lending Pool Contract)
2.1 Cargo.toml
[package]
name = "lending-pool"
version = "1.0.0"
edition = "2021"
description = "MSG Chain Lending Protocol - Compound/Aave style"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
library = []
[dependencies]
cosmwasm-std = { version = "1.5", features = ["staking"] }
cosmwasm-storage = "1.5"
cw-storage-plus = "1.2"
cw2 = "1.1"
cw20 = "1.1"
cw20-base = "1.1"
cosmwasm-schema = "1.5"
serde = { version = "1.0", features = ["derive"] }
schemars = "0.8"
thiserror = "1.0"
uint = "0.9"
[dev-dependencies]
cw-multi-test = "1.1"
anyhow = "1"
2.2 src/state.rs — 状态存储
use cosmwasm_std::{Addr, Decimal, Uint128};
use cw_storage_plus::{Item, Map};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
pub owner: Addr,
pub collateral_denom: String,
pub borrow_denom: String,
pub collateral_factor: Decimal,
pub liquidation_threshold: Decimal,
pub liquidation_bonus: Decimal,
pub reserve_factor: Decimal,
pub interest_model: InterestModelConfig,
pub oracle_address: Addr,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InterestModelConfig {
pub model_type: InterestModelType,
pub base_rate: Decimal,
pub multiplier_per_block: Decimal,
pub jump_multiplier_per_block: Decimal,
pub kink: Decimal,
pub blocks_per_year: Uint128,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum InterestModelType {
JumpRate,
Linear,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct UserInfo {
pub collateral: Uint128,
pub borrows: Uint128,
pub borrow_index: Decimal,
pub last_updated: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PoolState {
pub total_collateral: Uint128,
pub total_borrows: Uint128,
pub total_reserves: Uint128,
pub borrow_index: Decimal,
pub supply_index: Decimal,
pub last_updated: u64,
pub borrow_rate: Decimal,
pub supply_rate: Decimal,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PriceInfo {
pub price: Decimal,
pub updated_at: u64,
pub is_valid: bool,
}
pub const CONFIG: Item<Config> = Item::new("config");
pub const POOL: Item<PoolState> = Item::new("pool");
pub const USERS: Map<&Addr, UserInfo> = Map::new("users");
pub const BLOCKS_PER_YEAR: Uint128 = Uint128::new(6_307_200);
2.3 src/msg.rs — 消息定义
use cosmwasm_std::{Decimal, Uint128};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::state::InterestModelConfig;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
pub owner: String,
pub collateral_denom: String,
pub borrow_denom: String,
pub collateral_factor: Decimal,
pub liquidation_threshold: Decimal,
pub liquidation_bonus: Decimal,
pub reserve_factor: Decimal,
pub interest_model: InterestModelConfig,
pub oracle_address: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum ExecuteMsg {
DepositCollateral { amount: Uint128 },
WithdrawCollateral { amount: Uint128 },
Borrow { amount: Uint128 },
RepayBorrow { amount: Uint128 },
Liquidate { borrower: String, repay_amount: Uint128, min_collateral: Uint128 },
UpdateConfig {
owner: Option<String>,
collateral_factor: Option<Decimal>,
liquidation_threshold: Option<Decimal>,
liquidation_bonus: Option<Decimal>,
reserve_factor: Option<Decimal>,
interest_model: Option<InterestModelConfig>,
oracle_address: Option<String>,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum QueryMsg {
GetUserInfo { address: String },
GetPoolState {},
GetInterestRate {},
GetLiquidationStatus { address: String },
GetConfig {},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct UserInfoResponse {
pub address: String,
pub collateral: Uint128,
pub borrows: Uint128,
pub borrow_index: Decimal,
pub health_factor: Decimal,
pub can_liquidate: bool,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct PoolStateResponse {
pub total_collateral: Uint128,
pub total_borrows: Uint128,
pub total_reserves: Uint128,
pub borrow_index: Decimal,
pub supply_index: Decimal,
pub borrow_rate: Decimal,
pub supply_rate: Decimal,
pub utilization_rate: Decimal,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InterestRateResponse {
pub borrow_rate: Decimal,
pub supply_rate: Decimal,
pub utilization_rate: Decimal,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct LiquidationStatusResponse {
pub health_factor: Decimal,
pub can_liquidate: bool,
pub max_liquidatable: Uint128,
pub collateral_price: Decimal,
pub borrow_price: Decimal,
}
2.4 src/error.rs — 错误定义
use cosmwasm_std::StdError;
use thiserror::Error;
#[derive(Error, Debug, PartialEq)]
pub enum ContractError {
#[error("{0}")]
Std(#[from] StdError),
#[error("Unauthorized")]
Unauthorized,
#[error("Insufficient collateral")]
InsufficientCollateral,
#[error("Insufficient liquidity in pool")]
InsufficientLiquidity,
#[error("Amount is zero")]
ZeroAmount,
#[error("Health factor too low, would cause liquidation")]
HealthFactorTooLow,
#[error("Borrow cap exceeded for this pool")]
BorrowCapExceeded,
#[error("Borrower is not liquidatable")]
NotLiquidatable,
#[error("Liquidation amount exceeds maximum liquidatable")]
LiquidationAmountExceeded,
#[error("Slippage: seized collateral below minimum")]
SlippageExceeded,
#[error("Overflow in calculation")]
Overflow,
#[error("Oracle price not available")]
OraclePriceNotAvailable,
#[error("Price stale")]
PriceStale,
#[error("Invalid address")]
InvalidAddress,
}
2.5 src/contract.rs — 合约入口
use cosmwasm_std::{
attr, from_binary, to_binary, Addr, BankMsg, Coin, CosmosMsg, Decimal, Deps, DepsMut,
Env, MessageInfo, Response, StdResult, Uint128, WasmMsg, WasmQuery,
};
use cw2::set_contract_version;
use crate::error::ContractError;
use crate::msg::{
ExecuteMsg, InstantiateMsg, InterestRateResponse, LiquidationStatusResponse, PoolStateResponse,
QueryMsg, UserInfoResponse,
};
use crate::oracle::query_oracle_price;
use crate::state::{
Config, InterestModelConfig, PoolState, UserInfo, CONFIG, POOL, USERS,
};
const CONTRACT_NAME: &str = "msgchain-lending-pool";
const CONTRACT_VERSION: &str = "1.0.0";
pub fn instantiate(
deps: DepsMut,
env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
let owner = deps.api.addr_validate(&msg.owner)?;
let oracle_address = deps.api.addr_validate(&msg.oracle_address)?;
let config = Config {
owner,
collateral_denom: msg.collateral_denom,
borrow_denom: msg.borrow_denom,
collateral_factor: msg.collateral_factor,
liquidation_threshold: msg.liquidation_threshold,
liquidation_bonus: msg.liquidation_bonus,
reserve_factor: msg.reserve_factor,
interest_model: msg.interest_model,
oracle_address,
};
CONFIG.save(deps.storage, &config)?;
let pool = PoolState {
total_collateral: Uint128::zero(),
total_borrows: Uint128::zero(),
total_reserves: Uint128::zero(),
borrow_index: Decimal::one(),
supply_index: Decimal::one(),
last_updated: env.block.height,
borrow_rate: Decimal::zero(),
supply_rate: Decimal::zero(),
};
POOL.save(deps.storage, &pool)?;
Ok(Response::new()
.add_attribute("action", "instantiate")
.add_attribute("owner", config.owner)
.add_attribute("collateral_denom", &config.collateral_denom)
.add_attribute("borrow_denom", &config.borrow_denom)
.add_attribute("collateral_factor", config.collateral_factor.to_string()))
}
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::DepositCollateral { amount } => execute_deposit_collateral(deps, env, info, amount),
ExecuteMsg::WithdrawCollateral { amount } => execute_withdraw_collateral(deps, env, info, amount),
ExecuteMsg::Borrow { amount } => execute_borrow(deps, env, info, amount),
ExecuteMsg::RepayBorrow { amount } => execute_repay_borrow(deps, env, info, amount),
ExecuteMsg::Liquidate { borrower, repay_amount, min_collateral } => {
execute_liquidate(deps, env, info, borrower, repay_amount, min_collateral)
}
ExecuteMsg::UpdateConfig { owner, collateral_factor, liquidation_threshold,
liquidation_bonus, reserve_factor, interest_model, oracle_address } => {
execute_update_config(deps, env, info, owner, collateral_factor,
liquidation_threshold, liquidation_bonus, reserve_factor, interest_model, oracle_address)
}
}
}
pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Vec<u8>> {
match msg {
QueryMsg::GetUserInfo { address } => to_binary(&query_user_info(deps, env, address)?),
QueryMsg::GetPoolState {} => to_binary(&query_pool_state(deps, env)?),
QueryMsg::GetInterestRate {} => to_binary(&query_interest_rate(deps, env)?),
QueryMsg::GetLiquidationStatus { address } => to_binary(&query_liquidation_status(deps, env, address)?),
QueryMsg::GetConfig {} => to_binary(&CONFIG.load(deps.storage)?),
}
}
2.6 src/execute.rs — 执行逻辑
use cosmwasm_std::{
attr, BankMsg, Coin, Decimal, DepsMut, Env, MessageInfo, Response, Uint128,
};
use crate::error::ContractError;
use crate::interest::{
calculate_borrow_rate, calculate_supply_rate, update_borrow_index,
};
use crate::oracle::query_oracle_price;
use crate::state::{
Config, PoolState, UserInfo, CONFIG, POOL, USERS,
};
pub fn execute_deposit_collateral(
deps: DepsMut, env: Env, info: MessageInfo, amount: Uint128,
) -> Result<Response, ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount); }
let config = CONFIG.load(deps.storage)?;
let mut pool = POOL.load(deps.storage)?;
let user_addr = info.sender.clone();
let received = info.funds.iter()
.find(|c| c.denom == config.collateral_denom)
.map(|c| c.amount).unwrap_or(Uint128::zero());
if received < amount { return Err(ContractError::InsufficientCollateral); }
accrue_interest(&config, &mut pool, env.block.height);
let mut user = USERS.may_load(deps.storage, &user_addr)?.unwrap_or(UserInfo {
collateral: Uint128::zero(), borrows: Uint128::zero(),
borrow_index: pool.borrow_index, last_updated: env.block.height,
});
user.collateral = user.collateral.checked_add(amount)?;
user.last_updated = env.block.height;
pool.total_collateral = pool.total_collateral.checked_add(amount)?;
USERS.save(deps.storage, &user_addr, &user)?;
POOL.save(deps.storage, &pool)?;
Ok(Response::new().add_attribute("action", "deposit_collateral")
.add_attribute("user", user_addr).add_attribute("amount", amount.to_string())
.add_attribute("denom", &config.collateral_denom))
}
pub fn execute_withdraw_collateral(
deps: DepsMut, env: Env, info: MessageInfo, amount: Uint128,
) -> Result<Response, ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount); }
let config = CONFIG.load(deps.storage)?;
let mut pool = POOL.load(deps.storage)?;
let user_addr = info.sender;
accrue_interest(&config, &mut pool, env.block.height);
let mut user = USERS.may_load(deps.storage, &user_addr)?
.ok_or(ContractError::InsufficientCollateral)?;
if user.collateral < amount { return Err(ContractError::InsufficientCollateral); }
user.collateral = user.collateral.checked_sub(amount)?;
pool.total_collateral = pool.total_collateral.checked_sub(amount)?;
if user.borrows > Uint128::zero() {
let collateral_price = query_oracle_price(deps.as_ref(), &config.oracle_address, &config.collateral_denom)?;
let borrow_price = query_oracle_price(deps.as_ref(), &config.oracle_address, &config.borrow_denom)?;
let collateral_value = Decimal::from_ratio(user.collateral, Uint128::new(1)).checked_mul(collateral_price)?;
let borrow_value = Decimal::from_ratio(user.borrows, Uint128::new(1)).checked_mul(borrow_price)?;
if borrow_value > collateral_value.checked_mul(config.collateral_factor)? {
return Err(ContractError::HealthFactorTooLow);
}
}
user.last_updated = env.block.height;
USERS.save(deps.storage, &user_addr, &user)?;
POOL.save(deps.storage, &pool)?;
let send_msg = BankMsg::Send {
to_address: user_addr.to_string(),
amount: vec![Coin { denom: config.collateral_denom.clone(), amount }],
};
Ok(Response::new().add_message(send_msg)
.add_attribute("action", "withdraw_collateral")
.add_attribute("user", user_addr).add_attribute("amount", amount.to_string())
.add_attribute("denom", &config.collateral_denom))
}
pub fn execute_borrow(
deps: DepsMut, env: Env, info: MessageInfo, amount: Uint128,
) -> Result<Response, ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount); }
let config = CONFIG.load(deps.storage)?;
let mut pool = POOL.load(deps.storage)?;
let user_addr = info.sender;
accrue_interest(&config, &mut pool, env.block.height);
let mut user = USERS.may_load(deps.storage, &user_addr)?.unwrap_or(UserInfo {
collateral: Uint128::zero(), borrows: Uint128::zero(),
borrow_index: pool.borrow_index, last_updated: env.block.height,
});
if user.collateral.is_zero() { return Err(ContractError::InsufficientCollateral); }
let available = pool.total_collateral.checked_sub(pool.total_borrows)?;
if available < amount { return Err(ContractError::InsufficientLiquidity); }
let collateral_price = query_oracle_price(deps.as_ref(), &config.oracle_address, &config.collateral_denom)?;
let borrow_price = query_oracle_price(deps.as_ref(), &config.oracle_address, &config.borrow_denom)?;
let collateral_value = Decimal::from_ratio(user.collateral, Uint128::new(1)).checked_mul(collateral_price)?;
let new_borrow_value = Decimal::from_ratio(user.borrows.checked_add(amount)?, Uint128::new(1)).checked_mul(borrow_price)?;
if new_borrow_value > collateral_value.checked_mul(config.collateral_factor)? {
return Err(ContractError::HealthFactorTooLow);
}
if user.borrows > Uint128::zero() {
let indexed_value = Decimal::from_ratio(user.borrows, Uint128::new(1))
.checked_mul(pool.borrow_index)?.checked_div(user.borrow_index)?;
user.borrows = Uint128::try_from(indexed_value * Uint128::new(1))
.map_err(|_| ContractError::Overflow)?.checked_add(amount)?;
} else { user.borrows = amount; }
user.borrow_index = pool.borrow_index;
user.last_updated = env.block.height;
pool.total_borrows = pool.total_borrows.checked_add(amount)?;
USERS.save(deps.storage, &user_addr, &user)?;
POOL.save(deps.storage, &pool)?;
let send_msg = BankMsg::Send {
to_address: user_addr.to_string(),
amount: vec![Coin { denom: config.borrow_denom.clone(), amount }],
};
Ok(Response::new().add_message(send_msg)
.add_attribute("action", "borrow").add_attribute("user", user_addr)
.add_attribute("amount", amount.to_string()).add_attribute("denom", &config.borrow_denom))
}
pub fn execute_repay_borrow(
deps: DepsMut, env: Env, info: MessageInfo, amount: Uint128,
) -> Result<Response, ContractError> {
if amount.is_zero() { return Err(ContractError::ZeroAmount); }
let config = CONFIG.load(deps.storage)?;
let mut pool = POOL.load(deps.storage)?;
let user_addr = info.sender.clone();
accrue_interest(&config, &mut pool, env.block.height);
let mut user = USERS.may_load(deps.storage, &user_addr)?
.ok_or(ContractError::InsufficientCollateral)?;
let received = info.funds.iter()
.find(|c| c.denom == config.borrow_denom)
.map(|c| c.amount).unwrap_or(Uint128::zero());
if received < amount { return Err(ContractError::InsufficientLiquidity); }
let current_borrow_value = Decimal::from_ratio(user.borrows, Uint128::new(1))
.checked_mul(pool.borrow_index)?.checked_div(user.borrow_index)?;
let current_borrows = Uint128::try_from(current_borrow_value * Uint128::new(1))
.map_err(|_| ContractError::Overflow)?;
let repay_amount = if amount > current_borrows { current_borrows } else { amount };
let interest_accrued = current_borrows.checked_sub(user.borrows)?;
let reserve = Uint128::try_from(
Decimal::from_ratio(interest_accrued, Uint128::new(1))
.checked_mul(config.reserve_factor)? * Uint128::new(1))
.map_err(|_| ContractError::Overflow)?;
pool.total_reserves = pool.total_reserves.checked_add(reserve)?;
user.borrows = current_borrows.checked_sub(repay_amount)?;
user.borrow_index = pool.borrow_index;
user.last_updated = env.block.height;
pool.total_borrows = pool.total_borrows.checked_sub(repay_amount)?;
USERS.save(deps.storage, &user_addr, &user)?;
POOL.save(deps.storage, &pool)?;
Ok(Response::new().add_attribute("action", "repay_borrow")
.add_attribute("user", user_addr).add_attribute("amount", repay_amount.to_string())
.add_attribute("denom", &config.borrow_denom).add_attribute("reserve", reserve.to_string()))
}
pub fn execute_liquidate(
deps: DepsMut, env: Env, info: MessageInfo,
borrower: String, repay_amount: Uint128, min_collateral: Uint128,
) -> Result<Response, ContractError> {
if repay_amount.is_zero() { return Err(ContractError::ZeroAmount); }
let config = CONFIG.load(deps.storage)?;
let mut pool = POOL.load(deps.storage)?;
let borrower_addr = deps.api.addr_validate(&borrower)?;
let liquidator_addr = info.sender;
accrue_interest(&config, &mut pool, env.block.height);
let user = USERS.may_load(deps.storage, &borrower_addr)?
.ok_or(ContractError::NotLiquidatable)?;
if user.borrows.is_zero() { return Err(ContractError::NotLiquidatable); }
let collateral_price = query_oracle_price(deps.as_ref(), &config.oracle_address, &config.collateral_denom)?;
let borrow_price = query_oracle_price(deps.as_ref(), &config.oracle_address, &config.borrow_denom)?;
let collateral_value = Decimal::from_ratio(user.collateral, Uint128::new(1)).checked_mul(collateral_price)?;
let borrow_value = Decimal::from_ratio(user.borrows, Uint128::new(1)).checked_mul(borrow_price)?;
let health_factor = if borrow_value.is_zero() {
Decimal::from_ratio(Uint128::new(100), Uint128::new(1))
} else { collateral_value.checked_mul(config.collateral_factor)?.checked_div(borrow_value)? };
if health_factor >= Decimal::one() { return Err(ContractError::NotLiquidatable); }
let close_factor = Decimal::percent(50);
let max_liquidate = Uint128::try_from(
Decimal::from_ratio(user.borrows, Uint128::new(1)).checked_mul(close_factor)? * Uint128::new(1))
.map_err(|_| ContractError::Overflow)?;
let actual_repay = if repay_amount > max_liquidate { max_liquidate } else { repay_amount };
let received = info.funds.iter()
.find(|c| c.denom == config.borrow_denom)
.map(|c| c.amount).unwrap_or(Uint128::zero());
if received < actual_repay { return Err(ContractError::InsufficientLiquidity); }
let repay_value = Decimal::from_ratio(actual_repay, Uint128::new(1)).checked_mul(borrow_price)?;
let bonus = Decimal::one().checked_add(config.liquidation_bonus)?;
let seize_amount = Uint128::try_from(
repay_value.checked_mul(bonus)?.checked_div(collateral_price)? * Uint128::new(1))
.map_err(|_| ContractError::Overflow)?;
let actual_seize = if seize_amount > user.collateral { user.collateral } else { seize_amount };
if actual_seize < min_collateral { return Err(ContractError::SlippageExceeded); }
pool.total_collateral = pool.total_collateral.checked_sub(actual_seize)?;
pool.total_borrows = pool.total_borrows.checked_sub(actual_repay)?;
let current_borrow_value = Decimal::from_ratio(user.borrows, Uint128::new(1))
.checked_mul(pool.borrow_index)?.checked_div(user.borrow_index)?;
let remaining_borrows = Uint128::try_from(
(current_borrow_value - Decimal::from_ratio(actual_repay, Uint128::new(1))) * Uint128::new(1))
.map_err(|_| ContractError::Overflow)?;
let mut updated_user = user.clone();
updated_user.borrows = remaining_borrows;
updated_user.collateral = user.collateral.checked_sub(actual_seize)?;
updated_user.borrow_index = pool.borrow_index;
updated_user.last_updated = env.block.height;
USERS.save(deps.storage, &borrower_addr, &updated_user)?;
POOL.save(deps.storage, &pool)?;
let send_msg = BankMsg::Send {
to_address: liquidator_addr.to_string(),
amount: vec![Coin { denom: config.collateral_denom.clone(), amount: actual_seize }],
};
Ok(Response::new().add_message(send_msg)
.add_attribute("action", "liquidate").add_attribute("liquidator", liquidator_addr)
.add_attribute("borrower", borrower).add_attribute("repay_amount", actual_repay.to_string())
.add_attribute("seize_amount", actual_seize.to_string()).add_attribute("health_factor", health_factor.to_string()))
}
pub fn execute_update_config(
deps: DepsMut, _env: Env, info: MessageInfo,
owner: Option<String>, collateral_factor: Option<Decimal>,
liquidation_threshold: Option<Decimal>, liquidation_bonus: Option<Decimal>,
reserve_factor: Option<Decimal>, interest_model: Option<InterestModelConfig>,
oracle_address: Option<String>,
) -> Result<Response, ContractError> {
let mut config = CONFIG.load(deps.storage)?;
if info.sender != config.owner { return Err(ContractError::Unauthorized); }
if let Some(o) = owner { config.owner = deps.api.addr_validate(&o)?; }
if let Some(cf) = collateral_factor { config.collateral_factor = cf; }
if let Some(lt) = liquidation_threshold { config.liquidation_threshold = lt; }
if let Some(lb) = liquidation_bonus { config.liquidation_bonus = lb; }
if let Some(rf) = reserve_factor { config.reserve_factor = rf; }
if let Some(im) = interest_model { config.interest_model = im; }
if let Some(oa) = oracle_address { config.oracle_address = deps.api.addr_validate(&oa)?; }
CONFIG.save(deps.storage, &config)?;
Ok(Response::new().add_attribute("action", "update_config").add_attribute("owner", config.owner))
}
fn accrue_interest(config: &Config, pool: &mut PoolState, current_block: u64) {
let blocks_elapsed = current_block - pool.last_updated;
if blocks_elapsed == 0 { return; }
let utilization = if pool.total_borrows.is_zero() { Decimal::zero() }
else { Decimal::from_ratio(pool.total_borrows, pool.total_collateral) };
let borrow_rate = calculate_borrow_rate(&config.interest_model, utilization);
let blocks_dec = Uint128::from(blocks_elapsed);
let factor = Decimal::one().checked_add(
borrow_rate.checked_mul(Decimal::from_ratio(blocks_dec, Uint128::new(1)))
.unwrap_or(Decimal::zero())).unwrap_or(Decimal::one());
pool.borrow_index = pool.borrow_index.checked_mul(factor).unwrap_or(pool.borrow_index);
pool.borrow_rate = borrow_rate;
pool.supply_rate = calculate_supply_rate(borrow_rate, utilization, config.reserve_factor);
let new_borrows = Uint128::try_from(
Decimal::from_ratio(pool.total_borrows, Uint128::new(1))
.checked_mul(borrow_rate).unwrap_or(Decimal::zero())
.checked_mul(Decimal::from_ratio(blocks_dec, Uint128::new(1))).unwrap_or(Decimal::zero())
* Uint128::new(1)).unwrap_or(Uint128::zero());
pool.total_borrows = pool.total_borrows.checked_add(new_borrows).unwrap_or(pool.total_borrows);
pool.last_updated = current_block;
}
2.7 src/query.rs — 查询逻辑
use cosmwasm_std::{Decimal, Deps, Env, StdResult, Uint128};
use crate::interest::{calculate_borrow_rate, calculate_utilization};
use crate::msg::{InterestRateResponse, LiquidationStatusResponse, PoolStateResponse, UserInfoResponse};
use crate::oracle::query_oracle_price;
use crate::state::{PoolState, UserInfo, CONFIG, POOL, USERS};
pub fn query_user_info(deps: Deps, _env: Env, address: String) -> StdResult<UserInfoResponse> {
let config = CONFIG.load(deps.storage)?;
let pool = POOL.load(deps.storage)?;
let addr = deps.api.addr_validate(&address)?;
let user = USERS.may_load(deps.storage, &addr)?.unwrap_or(UserInfo {
collateral: Uint128::zero(), borrows: Uint128::zero(),
borrow_index: pool.borrow_index, last_updated: 0,
});
let cp = query_oracle_price(deps, &config.oracle_address, &config.collateral_denom).unwrap_or(Decimal::one());
let bp = query_oracle_price(deps, &config.oracle_address, &config.borrow_denom).unwrap_or(Decimal::one());
let cv = Decimal::from_ratio(user.collateral, Uint128::new(1)).checked_mul(cp).unwrap_or(Decimal::zero());
let bv = Decimal::from_ratio(user.borrows, Uint128::new(1)).checked_mul(bp).unwrap_or(Decimal::zero());
let hf = if bv.is_zero() { Decimal::from_ratio(Uint128::new(100), Uint128::new(1)) }
else { cv.checked_mul(config.collateral_factor).unwrap_or(Decimal::zero())
.checked_div(bv).unwrap_or(Decimal::zero()) };
Ok(UserInfoResponse { address, collateral: user.collateral, borrows: user.borrows,
borrow_index: user.borrow_index, health_factor: hf, can_liquidate: hf < Decimal::one() })
}
pub fn query_pool_state(deps: Deps, _env: Env) -> StdResult<PoolStateResponse> {
let config = CONFIG.load(deps.storage)?;
let pool = POOL.load(deps.storage)?;
let u = calculate_utilization(pool.total_collateral, pool.total_borrows);
let br = calculate_borrow_rate(&config.interest_model, u);
let sr = if pool.total_collateral.is_zero() || u.is_zero() { Decimal::zero() }
else { br.checked_mul(u).unwrap_or(Decimal::zero())
.checked_mul(Decimal::one().checked_sub(config.reserve_factor).unwrap_or(Decimal::one()))
.unwrap_or(Decimal::zero()) };
Ok(PoolStateResponse { total_collateral: pool.total_collateral, total_borrows: pool.total_borrows,
total_reserves: pool.total_reserves, borrow_index: pool.borrow_index,
supply_index: pool.supply_index, borrow_rate: br, supply_rate: sr, utilization_rate: u })
}
pub fn query_interest_rate(deps: Deps, _env: Env) -> StdResult<InterestRateResponse> {
let config = CONFIG.load(deps.storage)?;
let pool = POOL.load(deps.storage)?;
let u = calculate_utilization(pool.total_collateral, pool.total_borrows);
let br = calculate_borrow_rate(&config.interest_model, u);
let sr = if pool.total_collateral.is_zero() || u.is_zero() { Decimal::zero() }
else { br.checked_mul(u).unwrap_or(Decimal::zero())
.checked_mul(Decimal::one().checked_sub(config.reserve_factor).unwrap_or(Decimal::one()))
.unwrap_or(Decimal::zero()) };
Ok(InterestRateResponse { borrow_rate: br, supply_rate: sr, utilization_rate: u })
}
pub fn query_liquidation_status(deps: Deps, _env: Env, address: String) -> StdResult<LiquidationStatusResponse> {
let config = CONFIG.load(deps.storage)?;
let pool = POOL.load(deps.storage)?;
let addr = deps.api.addr_validate(&address)?;
let user = USERS.may_load(deps.storage, &addr)?.unwrap_or(UserInfo {
collateral: Uint128::zero(), borrows: Uint128::zero(),
borrow_index: pool.borrow_index, last_updated: 0,
});
let cp = query_oracle_price(deps, &config.oracle_address, &config.collateral_denom).unwrap_or(Decimal::one());
let bp = query_oracle_price(deps, &config.oracle_address, &config.borrow_denom).unwrap_or(Decimal::one());
let cv = Decimal::from_ratio(user.collateral, Uint128::new(1)).checked_mul(cp).unwrap_or(Decimal::zero());
let bv = Decimal::from_ratio(user.borrows, Uint128::new(1)).checked_mul(bp).unwrap_or(Decimal::zero());
let hf = if bv.is_zero() { Decimal::from_ratio(Uint128::new(100), Uint128::new(1)) }
else { cv.checked_mul(config.collateral_factor).unwrap_or(Decimal::zero())
.checked_div(bv).unwrap_or(Decimal::zero()) };
let can_liquidate = !user.collateral.is_zero() && !user.borrows.is_zero() && hf < Decimal::one();
let max_liq = if can_liquidate {
Uint128::try_from(Decimal::from_ratio(user.borrows, Uint128::new(1))
.checked_mul(Decimal::percent(50)).unwrap_or(Decimal::zero()) * Uint128::new(1))
.unwrap_or(Uint128::zero())
} else { Uint128::zero() };
Ok(LiquidationStatusResponse { health_factor: hf, can_liquidate,
max_liquidatable: max_liq, collateral_price: cp, borrow_price: bp })
}
2.8 src/lib.rs — 库入口
pub mod contract;
pub mod error;
pub mod execute;
pub mod interest;
pub mod msg;
pub mod oracle;
pub mod query;
pub mod state;
pub use crate::error::ContractError;
pub use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
3. Part 2: 利率模型 (Interest Rate Model)
3.1 src/interest.rs — 完整利率模型实现
use cosmwasm_std::{Decimal, Uint128};
use crate::state::InterestModelConfig;
pub fn calculate_utilization(total_collateral: Uint128, total_borrows: Uint128) -> Decimal {
if total_collateral.is_zero() || total_borrows.is_zero() { return Decimal::zero(); }
Decimal::from_ratio(total_borrows, total_collateral)
}
pub fn calculate_borrow_rate(config: &InterestModelConfig, utilization: Decimal) -> Decimal {
match config.model_type {
crate::state::InterestModelType::JumpRate => calculate_jump_rate(utilization,
config.base_rate, config.multiplier_per_block, config.jump_multiplier_per_block, config.kink),
crate::state::InterestModelType::Linear => calculate_linear_rate(utilization,
config.base_rate, config.multiplier_per_block),
}
}
fn calculate_jump_rate(utilization: Decimal, base_rate: Decimal, multiplier: Decimal,
jump_multiplier: Decimal, kink: Decimal) -> Decimal {
if utilization <= kink {
let r = base_rate + multiplier.checked_mul(utilization).unwrap_or(Decimal::zero());
if r > Decimal::one() { Decimal::one() } else { r }
} else {
let excess = utilization.checked_sub(kink).unwrap_or(Decimal::zero());
let one_minus_kink = Decimal::one().checked_sub(kink).unwrap_or(Decimal::one());
if one_minus_kink.is_zero() { return Decimal::one(); }
let excess_rate = excess.checked_div(one_minus_kink).unwrap_or(Decimal::zero());
let r = base_rate + multiplier.checked_mul(kink).unwrap_or(Decimal::zero())
+ jump_multiplier.checked_mul(excess_rate).unwrap_or(Decimal::zero());
if r > Decimal::one() { Decimal::one() } else { r }
}
}
fn calculate_linear_rate(utilization: Decimal, base_rate: Decimal, multiplier: Decimal) -> Decimal {
let r = base_rate + multiplier.checked_mul(utilization).unwrap_or(Decimal::zero());
if r > Decimal::one() { Decimal::one() } else { r }
}
pub fn calculate_supply_rate(borrow_rate: Decimal, utilization: Decimal, reserve_factor: Decimal) -> Decimal {
if borrow_rate.is_zero() || utilization.is_zero() { return Decimal::zero(); }
let one_minus_reserve = Decimal::one().checked_sub(reserve_factor).unwrap_or(Decimal::one());
borrow_rate.checked_mul(utilization).unwrap_or(Decimal::zero())
.checked_mul(one_minus_reserve).unwrap_or(Decimal::zero())
}
pub fn update_borrow_index(old_index: Decimal, borrow_rate: Decimal, blocks_elapsed: u64) -> Decimal {
if blocks_elapsed == 0 { return old_index; }
let factor = Decimal::one().checked_add(borrow_rate.checked_mul(
Decimal::from_ratio(Uint128::from(blocks_elapsed), Uint128::new(1))
).unwrap_or(Decimal::zero())).unwrap_or(Decimal::one());
old_index.checked_mul(factor).unwrap_or(old_index)
}
pub fn calculate_apy(rate_per_block: Decimal, blocks_per_year: Uint128) -> Decimal {
let r = rate_per_block.checked_mul(Decimal::from_ratio(blocks_per_year, Uint128::new(1)))
.unwrap_or(Decimal::zero());
if r > Decimal::from_ratio(Uint128::new(5), Uint128::new(1)) { Decimal::from_ratio(Uint128::new(5), Uint128::new(1)) }
else { r }
}
pub fn default_jump_rate_config() -> InterestModelConfig {
InterestModelConfig {
model_type: crate::state::InterestModelType::JumpRate,
base_rate: Decimal::percent(2),
multiplier_per_block: Decimal::percent(10),
jump_multiplier_per_block: Decimal::percent(50),
kink: Decimal::percent(80),
blocks_per_year: Uint128::new(6_307_200),
}
}
3.2 利率模型可视化
借款利率 (%)
^
| /
| /
| /
| /
| / (R2=50%)
| /
| /
| /|--- kink = 80%
| /
| / (R1=10%)
| /
| /
| / (R0=2%)
|/
└──────────────────────────────> 资金利用率 (%)
0 100
3.3 利率模型测试
#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::{Decimal, Uint128};
fn setup_jump_config() -> InterestModelConfig {
InterestModelConfig {
model_type: crate::state::InterestModelType::JumpRate,
base_rate: Decimal::from_ratio(2u128, 100u128),
multiplier_per_block: Decimal::from_ratio(10u128, 100u128),
jump_multiplier_per_block: Decimal::from_ratio(50u128, 100u128),
kink: Decimal::from_ratio(80u128, 100u128),
blocks_per_year: Uint128::new(6_307_200),
}
}
#[test] fn test_utilization_zero() {
assert_eq!(calculate_utilization(Uint128::zero(), Uint128::zero()), Decimal::zero());
}
#[test] fn test_utilization_half() {
assert_eq!(calculate_utilization(Uint128::new(1000), Uint128::new(500)), Decimal::percent(50));
}
#[test] fn test_utilization_full() {
assert_eq!(calculate_utilization(Uint128::new(1000), Uint128::new(1000)), Decimal::percent(100));
}
#[test] fn test_jump_rate_below_kink() {
let config = setup_jump_config();
assert_eq!(calculate_borrow_rate(&config, Decimal::percent(40)), Decimal::percent(6));
}
#[test] fn test_jump_rate_at_kink() {
let config = setup_jump_config();
assert_eq!(calculate_borrow_rate(&config, Decimal::percent(80)), Decimal::percent(10));
}
#[test] fn test_jump_rate_above_kink() {
let config = setup_jump_config();
assert_eq!(calculate_borrow_rate(&config, Decimal::percent(90)), Decimal::from_ratio(35u128, 100u128));
}
#[test] fn test_jump_rate_max() {
let config = setup_jump_config();
assert_eq!(calculate_borrow_rate(&config, Decimal::percent(100)), Decimal::percent(60));
}
#[test] fn test_supply_rate_with_reserve() {
assert_eq!(calculate_supply_rate(Decimal::percent(10), Decimal::percent(50), Decimal::percent(10)),
Decimal::from_ratio(45u128, 1000u128));
}
#[test] fn test_update_borrow_index() {
assert_eq!(update_borrow_index(Decimal::one(), Decimal::from_ratio(1u128, 10000u128), 100),
Decimal::from_ratio(101u128, 100u128));
}
#[test] fn test_no_blocks_elapsed() {
assert_eq!(update_borrow_index(Decimal::from_ratio(5u128, 1u128), Decimal::percent(10), 0),
Decimal::from_ratio(5u128, 1u128));
}
#[test] fn test_linear_rate() {
let config = InterestModelConfig {
model_type: crate::state::InterestModelType::Linear,
base_rate: Decimal::percent(2), multiplier_per_block: Decimal::percent(10),
jump_multiplier_per_block: Decimal::zero(), kink: Decimal::percent(80),
blocks_per_year: Uint128::new(6_307_200),
};
assert_eq!(calculate_borrow_rate(&config, Decimal::percent(50)), Decimal::percent(7));
}
}
3.4 利率模型参考值表
| 资金利用率 | Jump Rate 借款利率 | 说明 |
|---|---|---|
| 0% | 2.00% | 仅基础利率 |
| 20% | 4.00% | 低利用率 |
| 40% | 6.00% | 中等利用率 |
| 60% | 8.00% | 较高利用率 |
| 80% (Kink) | 10.00% | 最佳利用率拐点 |
| 85% | 22.50% | 超过拐点,利率急升 |
| 90% | 35.00% | 流动性紧张 |
| 95% | 47.50% | 严重紧张 |
| 100% | 60.00% | 理论最大值 |
4. Part 3: 预言机集成 (Oracle Integration)
4.1 src/oracle.rs — 预言机查询模块
use cosmwasm_std::{from_binary, Addr, Decimal, Deps, QueryRequest, StdError, StdResult, Uint128, WasmQuery};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct OraclePriceResponse {
pub denom: String,
pub price: Decimal,
pub decimals: u8,
pub updated_at: u64,
pub is_valid: bool,
pub sources: u32,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum OracleQueryMsg {
GetPrice { denom: String },
GetPrices { denoms: Vec<String> },
GetConfig {},
}
pub fn query_oracle_price(deps: Deps, oracle_address: &Addr, denom: &str) -> StdResult<Decimal> {
let msg = to_binary(&OracleQueryMsg::GetPrice { denom: denom.to_string() })?;
let response: OraclePriceResponse = deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart {
contract_addr: oracle_address.to_string(), msg,
}))?;
if !response.is_valid {
return Err(StdError::generic_err(format!("Oracle price for {} is not valid", denom)));
}
Ok(response.price)
}
pub fn query_oracle_prices(deps: Deps, oracle_address: &Addr, denoms: &[String]) -> StdResult<Vec<OraclePriceResponse>> {
let msg = to_binary(&OracleQueryMsg::GetPrices { denoms: denoms.to_vec() })?;
let responses: Vec<OraclePriceResponse> = deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart {
contract_addr: oracle_address.to_string(), msg,
}))?;
Ok(responses)
}
pub fn query_user_valuation(deps: Deps, oracle_address: &Addr, collateral_denom: &str, borrow_denom: &str,
collateral_amount: Uint128, borrow_amount: Uint128) -> StdResult<(Decimal, Decimal)> {
let cp = query_oracle_price(deps, oracle_address, collateral_denom)?;
let bp = query_oracle_price(deps, oracle_address, borrow_denom)?;
let cv = Decimal::from_ratio(collateral_amount, Uint128::new(1)).checked_mul(cp)
.map_err(|_| StdError::generic_err("Collateral overflow"))?;
let bv = Decimal::from_ratio(borrow_amount, Uint128::new(1)).checked_mul(bp)
.map_err(|_| StdError::generic_err("Borrow overflow"))?;
Ok((cv, bv))
}
pub fn check_price_freshness(price: &OraclePriceResponse, current_block: u64, max_age_blocks: u64) -> StdResult<()> {
let age = current_block.saturating_sub(price.updated_at);
if age > max_age_blocks {
return Err(StdError::generic_err(format!(
"Price for {} is stale. Age: {} blocks, max: {}", price.denom, age, max_age_blocks)));
}
Ok(())
}
pub fn query_twap_price(deps: Deps, oracle_address: &Addr, denom: &str, windows: u32) -> StdResult<Decimal> {
#[derive(Serialize, Deserialize)]
struct TwapQueryMsg { denom: String, windows: u32 }
let msg = to_binary(&TwapQueryMsg { denom: denom.to_string(), windows })?;
let response: OraclePriceResponse = deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart {
contract_addr: oracle_address.to_string(), msg,
}))?;
Ok(response.price)
}
4.2 预言机安全措施
pub struct OracleSafetyConfig {
pub max_price_age_blocks: u64,
pub min_sources: u32,
pub max_price_deviation: Decimal,
pub twap_enabled: bool,
pub circuit_breaker_enabled: bool,
}
impl Default for OracleSafetyConfig {
fn default() -> Self {
Self {
max_price_age_blocks: 360,
min_sources: 3,
max_price_deviation: Decimal::percent(20),
twap_enabled: true,
circuit_breaker_enabled: true,
}
}
}
pub fn check_price_deviation(old_price: Decimal, new_price: Decimal, max_deviation: Decimal) -> StdResult<()> {
if old_price.is_zero() { return Ok(()); }
let deviation = if new_price > old_price {
(new_price - old_price).checked_div(old_price)
} else { (old_price - new_price).checked_div(old_price) }.unwrap_or(Decimal::zero());
if deviation > max_deviation {
return Err(StdError::generic_err(format!("Price deviation {} exceeds max {}", deviation, max_deviation)));
}
Ok(())
}
4.3 价格查询流程
用户操作 (Borrow/Withdraw/Liquidate)
│
▼
借贷合约 ──WasmQuery──► Agent API 预言机合约
│ │
│ ┌────┴────┐
│ │ 价格聚合 │
│ │ 新鲜度检查 │
│ │ 偏差检查 │
│ │ 熔断检查 │
│ └────┬────┘
│ │
│ ┌────▼────┐
│ │ 价格可用? │
│ └────┬────┘
│ ┌────────┴────────┐
│ 是 否
│ │ │
│ ▼ ▼
│ 返回价格 ┌──────────┐
│ │ TWAP 备选 │
│ │ 或拒绝交易 │
│ └──────────┘
▼
继续执行请求操作
5. Part 4: 清算机器人 (Liquidation Bot)
5.1 package.json
{
"name": "msg-liquidation-bot",
"version": "1.0.0",
"description": "MSG Chain Lending Protocol Liquidation Bot",
"main": "dist/index.js",
"scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "ts-node src/index.ts", "lint": "eslint src/" },
"dependencies": {
"@cosmjs/cosmwasm-stargate": "^0.32.0",
"@cosmjs/stargate": "^0.32.0",
"@cosmjs/proto-signing": "^0.32.0",
"dotenv": "^16.3.1",
"winston": "^3.11.0",
"axios": "^1.6.2"
},
"devDependencies": { "@types/node": "^20.10.0", "typescript": "^5.3.2", "ts-node": "^10.9.1" }
}
5.2 tsconfig.json
{
"compilerOptions": { "target": "ES2020", "module": "commonjs", "outDir": "./dist", "rootDir": "./src",
"strict": true, "esModuleInterop": true, "skipLibCheck": true, "sourceMap": true },
"include": ["src/**/*"], "exclude": ["node_modules", "dist"]
}
5.3 src/index.ts — 清算机器人入口
import { LiquidationBot } from './bot';
import { Logger } from './logger';
import { Config, loadConfig } from './config';
async function main() {
const logger = Logger.getInstance();
logger.info('MSG Chain Liquidation Bot starting...');
try {
const config: Config = loadConfig();
const bot = new LiquidationBot(config);
logger.info('Running initial health factor scan...');
await bot.scanAndLiquidate();
const intervalMs = config.scanIntervalMs || 25_000;
logger.info(`Starting periodic scan every ${intervalMs}ms`);
setInterval(async () => {
try { await bot.scanAndLiquidate(); } catch (err) { logger.error('Scan cycle failed', err as Error); }
}, intervalMs);
if (config.watchEvents) { logger.info('Starting event watcher...'); await bot.watchLiquidationEvents(); }
const shutdown = async () => { logger.info('Shutting down...'); await bot.stop(); process.exit(0); };
process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);
logger.info('Liquidation bot running successfully');
} catch (err) { logger.error('Failed to start bot', err as Error); process.exit(1); }
}
main();
5.4 src/config.ts — 配置
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../.env') });
export interface Config {
rpcEndpoint: string; chainId: string; bech32Prefix: string;
lendingPoolAddress: string; oracleAddress: string;
liquidatorMnemonic: string; liquidatorAddress: string;
agentApiUrl: string; agentApiKey: string;
scanIntervalMs: number; healthFactorThreshold: number;
maxLiquidationsPerCycle: number; watchEvents: boolean;
collateralDenom: string; borrowDenom: string;
gasPrice: string; gasLimit: number;
}
export function loadConfig(): Config {
const config: Config = {
rpcEndpoint: process.env.RPC_ENDPOINT || 'http://localhost:26657',
chainId: process.env.CHAIN_ID || 'msg-chain-1',
bech32Prefix: process.env.BECH32_PREFIX || 'msg',
lendingPoolAddress: process.env.LENDING_POOL_ADDRESS || '',
oracleAddress: process.env.ORACLE_ADDRESS || '',
liquidatorMnemonic: process.env.LIQUIDATOR_MNEMONIC || '',
liquidatorAddress: process.env.LIQUIDATOR_ADDRESS || '',
agentApiUrl: process.env.AGENT_API_URL || 'http://localhost:8080',
agentApiKey: process.env.AGENT_API_KEY || '',
scanIntervalMs: parseInt(process.env.SCAN_INTERVAL_MS || '25000', 10),
healthFactorThreshold: parseFloat(process.env.HEALTH_FACTOR_THRESHOLD || '0.95'),
maxLiquidationsPerCycle: parseInt(process.env.MAX_LIQUIDATIONS_PER_CYCLE || '10', 10),
watchEvents: process.env.WATCH_EVENTS === 'true',
collateralDenom: process.env.COLLATERAL_DENOM || 'umsg',
borrowDenom: process.env.BORROW_DENOM || 'umsg',
gasPrice: process.env.GAS_PRICE || '1000000000',
gasLimit: parseInt(process.env.GAS_LIMIT || '500000', 10),
};
if (!config.lendingPoolAddress) throw new Error('LENDING_POOL_ADDRESS is required');
if (!config.liquidatorMnemonic) throw new Error('LIQUIDATOR_MNEMONIC is required');
return config;
}
5.5 src/logger.ts — 日志模块
import winston from 'winston';
export class Logger {
private static instance: winston.Logger;
static getInstance(): winston.Logger {
if (!Logger.instance) {
Logger.instance = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, ...meta }) =>
`${timestamp} [${level.toUpperCase()}] ${message}${Object.keys(meta).length ? ' ' + JSON.stringify(meta) : ''}`)
),
transports: [
new winston.transports.Console({
format: winston.format.combine(winston.format.colorize(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`))
}),
new winston.transports.File({ filename: 'logs/liquidation-error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/liquidation-combined.log' }),
],
});
}
return Logger.instance;
}
}
5.6 src/bot.ts — 清算机器人核心逻辑
import { CosmWasmClient, SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing';
import { GasPrice, calculateFee } from '@cosmjs/stargate';
import axios from 'axios';
import { Config } from './config';
import { Logger } from './logger';
import winston from 'winston';
interface UserPosition {
address: string; collateral: string; borrows: string;
healthFactor: number; canLiquidate: boolean; maxLiquidatable: string;
}
interface LiquidationResult {
borrower: string; repayAmount: string; seizeAmount: string;
healthFactor: number; txHash: string; success: boolean; error?: string;
}
export class LiquidationBot {
private config: Config;
private client!: CosmWasmClient;
private signingClient!: SigningCosmWasmClient;
private wallet!: DirectSecp256k1HdWallet;
private logger: winston.Logger;
private running = false;
private liquidatorAddress!: string;
constructor(config: Config) {
this.config = config;
this.logger = Logger.getInstance();
}
async initialize(): Promise<void> {
this.logger.info('Initializing bot...');
this.wallet = await DirectSecp256k1HdWallet.fromMnemonic(this.config.liquidatorMnemonic, { prefix: this.config.bech32Prefix });
const accounts = await this.wallet.getAccounts();
this.liquidatorAddress = accounts[0].address;
this.logger.info(`Liquidator: ${this.liquidatorAddress}`);
this.client = await CosmWasmClient.connect(this.config.rpcEndpoint);
const balance = await this.client.getBalance(this.liquidatorAddress, this.config.borrowDenom);
this.logger.info(`Balance: ${balance.amount} ${balance.denom}`);
if (BigInt(balance.amount) === 0n) this.logger.warn('Zero balance');
const gasPrice = GasPrice.fromString(`${this.config.gasPrice}${this.config.borrowDenom}`);
this.signingClient = await SigningCosmWasmClient.connectWithSigner(this.config.rpcEndpoint, this.wallet, { gasPrice });
this.running = true;
}
async stop(): Promise<void> { this.running = false; this.logger.info('Stopped'); }
async scanAndLiquidate(): Promise<void> {
if (!this.running) await this.initialize();
this.logger.info('Scanning for liquidatable positions...');
try {
const users = await this.getActiveBorrowers();
if (users.length === 0) { this.logger.debug('No borrowers found'); return; }
const liquidatable: UserPosition[] = [];
for (const addr of users) {
try {
const pos = await this.getUserPosition(addr);
if (pos.canLiquidate) { liquidatable.push(pos);
this.logger.warn(`Liquidatable: ${addr}, HF: ${pos.healthFactor}`); }
} catch (err) { this.logger.error(`Error querying ${addr}`, err as Error); }
}
if (liquidatable.length === 0) { this.logger.info('No liquidatable positions'); return; }
liquidatable.sort((a, b) => a.healthFactor - b.healthFactor);
const maxLiq = Math.min(this.config.maxLiquidationsPerCycle, liquidatable.length);
for (let i = 0; i < maxLiq; i++) {
try {
const result = await this.executeLiquidation(liquidatable[i]);
this.logger.info(`Liquidation ${result.success ? 'SUCCESS' : 'FAILED'}: ${result.borrower}`);
} catch (err) { this.logger.error(`Liquidation failed`, err as Error); }
}
} catch (err) { this.logger.error('Scan failed', err as Error); }
}
async getActiveBorrowers(): Promise<string[]> {
try {
const res = await axios.get(`${this.config.agentApiUrl}/api/v1/lending/borrowers`, {
headers: { 'X-API-Key': this.config.agentApiKey },
params: { contract: this.config.lendingPoolAddress, chain: this.config.chainId },
timeout: 10000,
});
return res.data.borrowers || [];
} catch { this.logger.warn('Agent API failed, using chain events'); }
try {
const txs = await this.client.searchTx([
{ key: 'action', value: 'borrow' }, { key: 'contract_address', value: this.config.lendingPoolAddress }
]);
const addrs = new Set<string>();
for (const tx of txs) { for (const ev of tx.events) {
if (ev.type === 'wasm') { for (const attr of ev.attributes) { if (attr.key === 'user') addrs.add(attr.value); } }
} }
return Array.from(addrs);
} catch { return []; }
}
async getUserPosition(address: string): Promise<UserPosition> {
const result: any = await this.client.queryContractSmart(this.config.lendingPoolAddress, { get_user_info: { address } });
return { address, collateral: result.collateral, borrows: result.borrows,
healthFactor: parseFloat(result.health_factor), canLiquidate: result.can_liquidate, maxLiquidatable: '0' };
}
async executeLiquidation(user: UserPosition): Promise<LiquidationResult> {
const repayAmount = user.maxLiquidatable || '0';
if (BigInt(repayAmount) === 0n) return { borrower: user.address, repayAmount: '0', seizeAmount: '0',
healthFactor: user.healthFactor, txHash: '', success: false, error: 'zero amount' };
try {
const fee = calculateFee(this.config.gasLimit, GasPrice.fromString(`${this.config.gasPrice}${this.config.borrowDenom}`));
const result = await this.signingClient.execute(this.liquidatorAddress, this.config.lendingPoolAddress,
{ liquidate: { borrower: user.address, repay_amount: repayAmount, min_collateral: '0' } },
fee, `Liquidate ${user.address}`, [{ denom: this.config.borrowDenom, amount: repayAmount }]);
return { borrower: user.address, repayAmount, seizeAmount: 'onchain', healthFactor: user.healthFactor,
txHash: result.transactionHash, success: true };
} catch (err: any) {
if (err.message?.includes('NotLiquidatable')) this.logger.info(`Already liquidated: ${user.address}`);
return { borrower: user.address, repayAmount, seizeAmount: '0', healthFactor: user.healthFactor,
txHash: '', success: false, error: err.message };
}
}
async watchLiquidationEvents(): Promise<void> {
const wsUrl = this.config.rpcEndpoint.replace('http://', 'ws://').replace('https://', 'wss://').replace('26657', '26657') + '/websocket';
try {
const WebSocket = require('ws');
const ws = new WebSocket(wsUrl);
ws.on('open', () => {
this.logger.info('WS connected');
ws.send(JSON.stringify({ jsonrpc: '2.0', method: 'subscribe', id: 1,
params: { query: `wasm.contract_address='${this.config.lendingPoolAddress}'` } }));
});
ws.on('message', async (data: string) => {
try { const msg = JSON.parse(data); if (msg.result?.events) await this.handleChainEvent(msg.result.events); } catch {}
});
ws.on('close', () => { this.logger.warn('WS closed, reconnecting...'); setTimeout(() => this.watchLiquidationEvents(), 5000); });
} catch (err) { this.logger.error('WS failed', err as Error); }
}
private async handleChainEvent(events: any[]): Promise<void> {
for (const ev of events) {
if (ev.type === 'wasm') {
const action = ev.attributes.find((a: any) => a.key === 'action')?.value;
if (action === 'borrow' || action === 'withdraw_collateral') {
const user = ev.attributes.find((a: any) => a.key === 'user')?.value;
if (user) { const pos = await this.getUserPosition(user); if (pos.canLiquidate) await this.executeLiquidation(pos); }
}
}
}
}
async fetchLiquidationOpportunitiesFromAgent(): Promise<UserPosition[]> {
try {
const res = await axios.get(`${this.config.agentApiUrl}/api/v1/lending/liquidations`, {
headers: { 'X-API-Key': this.config.agentApiKey },
params: { contract: this.config.lendingPoolAddress, chain: this.config.chainId,
threshold: this.config.healthFactorThreshold, limit: this.config.maxLiquidationsPerCycle },
timeout: 5000,
});
return (res.data.positions || []).map((p: any) => ({ address: p.address, collateral: p.collateral,
borrows: p.borrows, healthFactor: parseFloat(p.health_factor), canLiquidate: true, maxLiquidatable: p.max_liquidatable || '0' }));
} catch { return []; }
}
}
5.7 .env 配置模板
# MSG Chain RPC
RPC_ENDPOINT=http://localhost:26657
CHAIN_ID=msg-chain-1
BECH32_PREFIX=msg
LENDING_POOL_ADDRESS=msg1...
ORACLE_ADDRESS=msg1...
LIQUIDATOR_MNEMONIC="word1 word2 ... word24"
AGENT_API_URL=http://localhost:8080
AGENT_API_KEY=your-api-key
SCAN_INTERVAL_MS=25000
HEALTH_FACTOR_THRESHOLD=0.95
MAX_LIQUIDATIONS_PER_CYCLE=10
WATCH_EVENTS=true
COLLATERAL_DENOM=umsg
BORROW_DENOM=umsg
GAS_PRICE=1000000000attoMSG
GAS_LIMIT=500000
LOG_LEVEL=info
5.8 清算机器人部署
cd msg-liquidation-bot
npm install
cp .env.example .env
npm run build
npm start
# PM2 守护
npm install -g pm2
pm2 start dist/index.js --name msg-liquidation-bot
pm2 save && pm2 startup
6. Part 5: 前端集成 (Frontend)
6.1 项目结构
msg-lending-frontend/
├── public/
├── src/
│ ├── components/
│ │ ├── SupplyPanel.tsx
│ │ ├── BorrowPanel.tsx
│ │ ├── PositionCard.tsx
│ │ ├── PoolStats.tsx
│ │ ├── InterestRateChart.tsx
│ │ └── Dashboard.tsx
│ ├── hooks/
│ │ ├── useContract.ts
│ │ ├── useUserInfo.ts
│ │ └── usePoolData.ts
│ ├── utils/
│ │ ├── contract.ts
│ │ ├── format.ts
│ │ └── cosmjs.ts
│ ├── types/
│ │ └── index.ts
│ ├── App.tsx
│ └── index.tsx
├── package.json
├── tsconfig.json
└── config-overrides.js
6.2 package.json
{
"name": "msg-lending-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.2.0", "react-dom": "^18.2.0", "react-scripts": "5.0.1",
"@cosmjs/cosmwasm-stargate": "^0.32.0", "@cosmjs/stargate": "^0.32.0",
"recharts": "^2.10.3", "axios": "^1.6.2", "@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17", "typescript": "^5.3.2"
},
"scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" },
"browserslist": { "production": [">0.2%", "not dead", "not op_mini all"], "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] }
}
6.3 src/types/index.ts
export interface UserInfo {
address: string;
supplied: string;
borrowed: string;
collateral: string;
health_factor: string;
can_withdraw: boolean;
can_borrow: boolean;
can_liquidate: boolean;
}
export interface PoolInfo {
total_supplied: string;
total_borrowed: string;
total_reserves: string;
utilization_rate: string;
supply_apy: string;
borrow_apy: string;
supply_apr_history: { time: number; rate: number }[];
borrow_apr_history: { time: number; rate: number }[];
reserve_factor: string;
collateral_factor: string;
close_factor: string;
}
export interface InterestRatePoint {
utilization: number;
borrow_rate: number;
supply_rate: number;
}
export interface ContractConfig {
base_rate: string;
rate_slope1: string;
rate_slope2: string;
optimal_utilization: string;
reserve_factor: string;
collateral_factor: string;
close_factor: string;
}
export interface NetworkConfig {
chainId: string;
rpcEndpoint: string;
bech32Prefix: string;
lendingContract: string;
oracleContract: string;
nativeDenom: string;
nativeDecimals: number;
}
6.4 src/utils/cosmjs.ts
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { GasPrice, calculateFee } from '@cosmjs/stargate';
import { OfflineSigner, OfflineDirectSigner } from '@cosmjs/proto-signing';
import { NetworkConfig } from '../types';
export async function createSigningClient(signer: OfflineSigner, config: NetworkConfig): Promise<SigningCosmWasmClient> {
return SigningCosmWasmClient.connectWithSigner(config.rpcEndpoint, signer as OfflineDirectSigner, {
gasPrice: GasPrice.fromString(`1000000000attoMSG`),
});
}
export function calculateFeeSafe(gasLimit: number, gasPrice: string, denom: string) {
return calculateFee(gasLimit, GasPrice.fromString(`${gasPrice}${denom}`));
}
export async function getKeplrSigner(config: NetworkConfig): Promise<OfflineSigner> {
const keplr = (window as any).keplr;
if (!keplr) throw new Error('Keplr extension not found');
await keplr.enable(config.chainId);
const signer = await keplr.getOfflineSigner(config.chainId);
return signer;
}
export async function suggestChain(keplr: any, config: NetworkConfig) {
await keplr.experimentalSuggestChain({
chainId: config.chainId,
chainName: 'MSG Chain',
rpc: config.rpcEndpoint,
rest: config.rpcEndpoint.replace('26657', '1317'),
bip44: { coinType: 118 },
bech32Config: { bech32PrefixAccAddr: config.bech32Prefix, bech32PrefixAccPub: config.bech32Prefix + 'pub',
bech32PrefixValAddr: config.bech32Prefix + 'valoper', bech32PrefixValPub: config.bech32Prefix + 'valoperpub',
bech32PrefixConsAddr: config.bech32Prefix + 'valcons', bech32PrefixConsPub: config.bech32Prefix + 'valconspub' },
currencies: [{ coinDenom: 'MSG', coinMinDenom: 'umsg', coinDecimals: 18 }],
feeCurrencies: [{ coinDenom: 'MSG', coinMinDenom: 'umsg', coinDecimals: 18 }],
stakeCurrency: { coinDenom: 'MSG', coinMinDenom: 'umsg', coinDecimals: 18 },
features: ['cosmwasm'],
});
}
6.5 src/utils/format.ts
export function formatAmount(amount: string, decimals: number = 18, displayDecimals: number = 4): string {
const full = BigInt(amount);
const divisor = BigInt(10) ** BigInt(decimals);
const integer = full / divisor;
const fraction = full % divisor;
const fracStr = fraction.toString().padStart(decimals, '0').slice(0, displayDecimals);
return `${integer.toLocaleString()}.${fracStr}`;
}
export function parseAmount(amount: string, decimals: number = 18): string {
const parts = amount.split('.');
const integer = parts[0] || '0';
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
return (BigInt(integer) * BigInt(10) ** BigInt(decimals) + BigInt(fraction || '0')).toString();
}
export function formatHealthFactor(hf: string): { value: string; color: string; status: 'safe' | 'warning' | 'danger' } {
const val = parseFloat(hf);
let color: string; let status: 'safe' | 'warning' | 'danger';
if (val >= 2) { color = '#22c55e'; status = 'safe'; }
else if (val >= 1.1) { color = '#eab308'; status = 'warning'; }
else { color = '#ef4444'; status = 'danger'; }
return { value: val.toFixed(2), color, status };
}
export function formatPercentage(val: number | string, decimals: number = 2): string {
const n = typeof val === 'string' ? parseFloat(val) : val;
return `${(n * 100).toFixed(decimals)}%`;
}
export function formatUSD(amount: string, price: string, decimals: number = 18): string {
const amt = BigInt(amount) * BigInt(10) ** BigInt(18);
const prc = BigInt(price);
const usd = amt * prc / (BigInt(10) ** BigInt(24));
const whole = usd / BigInt(10) ** BigInt(18);
const frac = (usd % BigInt(10) ** BigInt(18)).toString().padStart(18, '0').slice(0, 2);
return `$${whole.toLocaleString()}.${frac}`;
}
export function truncateAddress(addr: string, prefix: number = 8, suffix: number = 6): string {
return `${addr.slice(0, prefix)}...${addr.slice(-suffix)}`;
}
export function shortenBigInt(val: string): string {
if (val.length <= 9) return val;
const billion = BigInt(10) ** BigInt(27); const million = BigInt(10) ** BigInt(24);
const thousand = BigInt(10) ** BigInt(21);
const v = BigInt(val);
if (v > billion) return `${formatAmount((v / BigInt(10) ** BigInt(18)).toString(), 0, 2)}B`;
if (v > million) return `${formatAmount((v / BigInt(10) ** BigInt(18)).toString(), 0, 2)}M`;
if (v > thousand) return `${formatAmount((v / BigInt(10) ** BigInt(18)).toString(), 0, 2)}K`;
return formatAmount(val, 18, 2);
}
6.6 src/utils/contract.ts
import { CosmWasmClient, SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { PoolInfo, UserInfo, ContractConfig, InterestRatePoint } from '../types';
export class LendingContract {
private client: CosmWasmClient; private address: string; private denom: string;
constructor(client: CosmWasmClient, address: string, denom: string) {
this.client = client; this.address = address; this.denom = denom;
}
async getPoolInfo(): Promise<PoolInfo> { return this.client.queryContractSmart(this.address, { get_pool_info: {} }); }
async getUserInfo(user: string): Promise<UserInfo> { return this.client.queryContractSmart(this.address, { get_user_info: { address: user } }); }
async getConfig(): Promise<ContractConfig> { return this.client.queryContractSmart(this.address, { get_config: {} }); }
async getInterestRatePoints(): Promise<InterestRatePoint[]> {
const cfg = await this.getConfig();
const points: InterestRatePoint[] = [];
for (let u = 0; u <= 100; u += 5) {
const util = u / 100;
let br: number;
if (util <= parseFloat(cfg.optimal_utilization)) { br = parseFloat(cfg.base_rate) + util * parseFloat(cfg.rate_slope1) / parseFloat(cfg.optimal_utilization); }
else { br = parseFloat(cfg.base_rate) + parseFloat(cfg.rate_slope1) + (util - parseFloat(cfg.optimal_utilization)) * parseFloat(cfg.rate_slope2) / (1 - parseFloat(cfg.optimal_utilization)); }
const sr = br * util * (1 - parseFloat(cfg.reserve_factor));
points.push({ utilization: util, borrow_rate: br, supply_rate: sr });
}
return points;
}
async supply(client: SigningCosmWasmClient, sender: string, amount: string, onBehalf?: string): Promise<string> {
const funds = [{ denom: this.denom, amount }];
const result = await client.execute(sender, this.address, { supply: { on_behalf: onBehalf || null } },
'auto', 'Supply', funds);
return result.transactionHash;
}
async withdraw(client: SigningCosmWasmClient, sender: string, amount: string): Promise<string> {
const result = await client.execute(sender, this.address, { withdraw: { amount } }, 'auto', 'Withdraw');
return result.transactionHash;
}
async borrow(client: SigningCosmWasmClient, sender: string, amount: string): Promise<string> {
const result = await client.execute(sender, this.address, { borrow: { amount } }, 'auto', 'Borrow');
return result.transactionHash;
}
async repay(client: SigningCosmWasmClient, sender: string, amount: string, borrower?: string): Promise<string> {
const funds = [{ denom: this.denom, amount }];
const result = await client.execute(sender, this.address, { repay: { borrower: borrower || null } },
'auto', 'Repay', funds);
return result.transactionHash;
}
async setCollateral(client: SigningCosmWasmClient, sender: string, enabled: boolean): Promise<string> {
const result = await client.execute(sender, this.address, { set_collateral: { enabled } }, 'auto', 'SetCollateral');
return result.transactionHash;
}
async liquidate(client: SigningCosmWasmClient, sender: string, borrower: string, repayAmount: string): Promise<string> {
const funds = [{ denom: this.denom, amount: repayAmount }];
const result = await client.execute(sender, this.address,
{ liquidate: { borrower, repay_amount: repayAmount, min_collateral: '0' } }, 'auto', 'Liquidate', funds);
return result.transactionHash;
}
async getPositions(users: string[]): Promise<UserInfo[]> {
const results = await Promise.allSettled(users.map(u => this.getUserInfo(u)));
return results.filter(r => r.status === 'fulfilled').map(r => (r as PromiseFulfilledResult<UserInfo>).value);
}
}
6.7 src/hooks/useContract.ts
import { useState, useEffect, useCallback } from 'react';
import { CosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { NetworkConfig, PoolInfo, UserInfo } from '../types';
import { LendingContract } from '../utils/contract';
export function useContract(config: NetworkConfig | null) {
const [client, setClient] = useState<CosmWasmClient | null>(null);
const [contract, setContract] = useState<LendingContract | null>(null);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!config) return;
let mounted = true;
const connect = async () => {
try {
const c = await CosmWasmClient.connect(config.rpcEndpoint);
if (!mounted) { c.disconnect(); return; }
setClient(c);
setContract(new LendingContract(c, config.lendingContract, config.nativeDenom));
setConnected(true); setError(null);
} catch (err: any) { setError(err.message); setConnected(false); }
};
connect();
return () => { mounted = false; client?.disconnect(); };
}, [config?.rpcEndpoint, config?.lendingContract]);
const refreshClient = useCallback(async () => {
if (!config) return;
try {
const c = await CosmWasmClient.connect(config.rpcEndpoint);
setClient(c);
setContract(new LendingContract(c, config.lendingContract, config.nativeDenom));
} catch (err: any) { setError(err.message); }
}, [config]);
return { client, contract, error, connected, refreshClient };
}
6.8 src/hooks/usePoolData.ts
import { useState, useEffect, useRef } from 'react';
import { PoolInfo, InterestRatePoint } from '../types';
import { LendingContract } from '../utils/contract';
export function usePoolData(contract: LendingContract | null, refreshInterval: number = 10_000) {
const [poolInfo, setPoolInfo] = useState<PoolInfo | null>(null);
const [ratePoints, setRatePoints] = useState<InterestRatePoint[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const fetch = async () => {
if (!contract) return;
try {
const [info, points] = await Promise.all([contract.getPoolInfo(), contract.getInterestRatePoints()]);
setPoolInfo(info); setRatePoints(points); setError(null);
} catch (err: any) { setError(err.message); }
setLoading(false);
};
useEffect(() => { fetch(); if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(fetch, refreshInterval);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [contract, refreshInterval]);
return { poolInfo, ratePoints, loading, error, refetch: fetch };
}
6.9 src/hooks/useUserInfo.ts
import { useState, useEffect, useRef } from 'react';
import { UserInfo } from '../types';
import { LendingContract } from '../utils/contract';
export function useUserInfo(contract: LendingContract | null, address: string | null, refreshInterval: number = 10_000) {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const fetch = async () => {
if (!contract || !address) return;
setLoading(true);
try {
const info = await contract.getUserInfo(address);
setUserInfo(info); setError(null);
} catch (err: any) { setError(err.message); }
setLoading(false);
};
useEffect(() => { fetch(); if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(fetch, refreshInterval);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [contract, address, refreshInterval]);
return { userInfo, loading, error, refetch: fetch };
}
6.10 src/components/PoolStats.tsx
import React from 'react';
import { PoolInfo } from '../types';
import { formatAmount, formatPercentage } from '../utils/format';
interface PoolStatsProps { poolInfo: PoolInfo | null; loading: boolean; error: string | null; }
const StatCard: React.FC<{ label: string; value: string; accent?: string }> = ({ label, value, accent }) => (
<div style={{ padding: '16px', borderRadius: '12px', background: '#1a1a2e', border: '1px solid #16213e', minWidth: '140px' }}>
<div style={{ fontSize: '12px', color: '#8892b0', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '1px' }}>{label}</div>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: accent || '#64ffda' }}>{value}</div>
</div>
);
const PoolStats: React.FC<PoolStatsProps> = ({ poolInfo, loading, error }) => {
if (loading) return <div style={{ color: '#8892b0' }}>Loading pool stats...</div>;
if (error) return <div style={{ color: '#ef4444' }}>Error: {error}</div>;
if (!poolInfo) return <div style={{ color: '#8892b0' }}>No data available</div>;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '24px' }}>
<StatCard label="Total Supplied" value={`${formatAmount(poolInfo.total_supplied)} MSG`} />
<StatCard label="Total Borrowed" value={`${formatAmount(poolInfo.total_borrowed)} MSG`} />
<StatCard label="Utilization" value={formatPercentage(poolInfo.utilization_rate)} accent="#eab308" />
<StatCard label="Supply APY" value={formatPercentage(poolInfo.supply_apy)} accent="#22c55e" />
<StatCard label="Borrow APR" value={formatPercentage(poolInfo.borrow_apy)} accent="#f97316" />
<StatCard label="Reserves" value={`${formatAmount(poolInfo.total_reserves)} MSG`} accent="#a78bfa" />
</div>
);
};
export default PoolStats;
6.11 src/components/InterestRateChart.tsx
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, ReferenceLine } from 'recharts';
import { InterestRatePoint } from '../types';
import { formatPercentage } from '../utils/format';
interface InterestRateChartProps { points: InterestRatePoint[]; currentUtilization?: string; }
const CustomTooltip = ({ active, payload, label }: any) => {
if (!active || !payload) return null;
return (
<div style={{ background: '#1a1a2e', border: '1px solid #16213e', padding: '12px', borderRadius: '8px' }}>
<div style={{ color: '#8892b0', marginBottom: '4px' }}>Utilization: {formatPercentage(label)}</div>
{payload.map((entry: any, idx: number) => (
<div key={idx} style={{ color: entry.color }}>{entry.name}: {formatPercentage(entry.value)}</div>
))}
</div>
);
};
const InterestRateChart: React.FC<InterestRateChartProps> = ({ points, currentUtilization }) => {
return (
<div style={{ background: '#1a1a2e', border: '1px solid #16213e', borderRadius: '12px', padding: '20px', marginBottom: '24px' }}>
<h3 style={{ color: '#ccd6f6', marginBottom: '16px', fontSize: '16px' }}>Interest Rate Model</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={points} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#16213e" />
<XAxis dataKey="utilization" tickFormatter={v => `${(v * 100).toFixed(0)}%`} stroke="#8892b0" fontSize={12} />
<YAxis tickFormatter={v => `${(v * 100).toFixed(0)}%`} stroke="#8892b0" fontSize={12} domain={[0, 'auto']} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: '12px', color: '#8892b0' }} />
<Line type="monotone" dataKey="borrow_rate" name="Borrow APR" stroke="#f97316" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="supply_rate" name="Supply APY" stroke="#22c55e" strokeWidth={2} dot={false} />
{currentUtilization && <ReferenceLine x={parseFloat(currentUtilization)} stroke="#eab308" strokeDasharray="5 5" label={{ value: 'Current', fill: '#eab308', fontSize: 12 }} />}
</LineChart>
</ResponsiveContainer>
</div>
);
};
export default InterestRateChart;
6.12 src/components/SupplyPanel.tsx
import React, { useState } from 'react';
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { LendingContract } from '../utils/contract';
interface SupplyPanelProps {
contract: LendingContract | null;
signingClient: SigningCosmWasmClient | null;
userAddress: string | null;
onSuccess: () => void;
}
const SupplyPanel: React.FC<SupplyPanelProps> = ({ contract, signingClient, userAddress, onSuccess }) => {
const [amount, setAmount] = useState(''); const [loading, setLoading] = useState(false);
const [txHash, setTxHash] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleMax = () => setAmount('1000');
const handleHalf = () => setAmount('500');
const submit = async () => {
if (!contract || !signingClient || !userAddress || !amount || BigInt(amount) === 0n) return;
setLoading(true); setError(null); setTxHash(null);
try {
const hash = await contract.supply(signingClient, userAddress, amount);
setTxHash(hash); setAmount(''); onSuccess();
} catch (err: any) { setError(err.message); } finally { setLoading(false); }
};
return (
<div style={{ background: '#1a1a2e', border: '1px solid #16213e', borderRadius: '12px', padding: '20px' }}>
<h3 style={{ color: '#ccd6f6', marginBottom: '16px' }}>Supply</h3>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<button onClick={handleHalf} style={btnStyle}>50%</button>
<button onClick={handleMax} style={btnStyle}>Max</button>
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<input type="text" value={amount} onChange={e => setAmount(e.target.value.replace(/[^0-9]/g, ''))}
placeholder="Amount (umsg)" style={inputStyle} disabled={loading} />
<button onClick={submit} disabled={loading || !amount} style={{ ...btnStyle, background: loading ? '#4a5568' : '#22c55e', color: '#fff', fontWeight: 'bold' }}>
{loading ? 'Processing...' : 'Supply'}
</button>
</div>
{txHash && <div style={{ color: '#22c55e', fontSize: '12px', wordBreak: 'break-all' }}>Tx: {txHash}</div>}
{error && <div style={{ color: '#ef4444', fontSize: '12px' }}>{error}</div>}
</div>
);
};
const inputStyle: React.CSSProperties = {
flex: 1, padding: '10px 12px', borderRadius: '8px', border: '1px solid #16213e',
background: '#0a0a1a', color: '#ccd6f6', fontSize: '14px', outline: 'none'
};
const btnStyle: React.CSSProperties = {
padding: '8px 16px', borderRadius: '8px', border: '1px solid #16213e',
background: '#16213e', color: '#8892b0', cursor: 'pointer', fontSize: '13px'
};
export default SupplyPanel;
6.13 src/components/BorrowPanel.tsx
import React, { useState } from 'react';
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { LendingContract } from '../utils/contract';
import { UserInfo } from '../types';
interface BorrowPanelProps {
contract: LendingContract | null;
signingClient: SigningCosmWasmClient | null;
userAddress: string | null;
userInfo: UserInfo | null;
onSuccess: () => void;
}
const BorrowPanel: React.FC<BorrowPanelProps> = ({ contract, signingClient, userAddress, userInfo, onSuccess }) => {
const [amount, setAmount] = useState(''); const [loading, setLoading] = useState(false);
const [txHash, setTxHash] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!contract || !signingClient || !userAddress || !amount || BigInt(amount) === 0n) return;
setLoading(true); setError(null); setTxHash(null);
try {
const hash = await contract.borrow(signingClient, userAddress, amount);
setTxHash(hash); setAmount(''); onSuccess();
} catch (err: any) { setError(err.message); } finally { setLoading(false); }
};
const repaySubmit = async () => {
if (!contract || !signingClient || !userAddress || !amount || BigInt(amount) === 0n) return;
setLoading(true); setError(null); setTxHash(null);
try {
const hash = await contract.repay(signingClient, userAddress, amount);
setTxHash(hash); setAmount(''); onSuccess();
} catch (err: any) { setError(err.message); } finally { setLoading(false); }
};
return (
<div style={{ background: '#1a1a2e', border: '1px solid #16213e', borderRadius: '12px', padding: '20px' }}>
<h3 style={{ color: '#ccd6f6', marginBottom: '16px' }}>Borrow / Repay</h3>
{userInfo && <div style={{ color: '#8892b0', fontSize: '13px', marginBottom: '8px' }}>
Can Borrow: <span style={{ color: userInfo.can_borrow ? '#22c55e' : '#eab308' }}>{userInfo.can_borrow ? 'Yes' : 'No'}</span>
</div>}
<input type="text" value={amount} onChange={e => setAmount(e.target.value.replace(/[^0-9]/g, ''))}
placeholder="Amount (umsg)" style={inputStyle} disabled={loading} />
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button onClick={submit} disabled={loading || !amount || !userInfo?.can_borrow}
style={{ ...btnStyle, background: loading ? '#4a5568' : '#f97316', color: '#fff', flex: 1 }}>Borrow</button>
<button onClick={repaySubmit} disabled={loading || !amount}
style={{ ...btnStyle, background: loading ? '#4a5568' : '#22c55e', color: '#fff', flex: 1 }}>Repay</button>
</div>
{txHash && <div style={{ color: '#22c55e', fontSize: '12px', marginTop: '8px', wordBreak: 'break-all' }}>Tx: {txHash}</div>}
{error && <div style={{ color: '#ef4444', fontSize: '12px', marginTop: '8px' }}>{error}</div>}
</div>
);
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 12px', borderRadius: '8px', border: '1px solid #16213e',
background: '#0a0a1a', color: '#ccd6f6', fontSize: '14px', outline: 'none', boxSizing: 'border-box'
};
const btnStyle: React.CSSProperties = {
padding: '10px 16px', borderRadius: '8px', border: '1px solid #16213e',
cursor: 'pointer', fontSize: '14px', fontWeight: 'bold'
};
export default BorrowPanel;
6.14 src/components/PositionCard.tsx
import React from 'react';
import { UserInfo } from '../types';
import { formatAmount, formatHealthFactor, truncateAddress } from '../utils/format';
interface PositionCardProps { userInfo: UserInfo | null; address: string | null; onSupply?: () => void; onWithdraw?: () => void; }
const PositionCard: React.FC<PositionCardProps> = ({ userInfo, address, onSupply, onWithdraw }) => {
if (!userInfo || !address) return (
<div style={{ background: '#1a1a2e', border: '1px solid #16213e', borderRadius: '12px', padding: '20px', textAlign: 'center', color: '#8892b0' }}>
Connect wallet to view position
</div>
);
const hf = formatHealthFactor(userInfo.health_factor);
return (
<div style={{ background: '#1a1a2e', border: `1px solid ${hf.color}40`, borderRadius: '12px', padding: '20px', marginBottom: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ color: '#ccd6f6', margin: 0, fontSize: '16px' }}>Your Position</h3>
<span style={{ fontSize: '12px', color: '#8892b0' }}>{truncateAddress(address)}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
<div><div style={{ fontSize: '12px', color: '#8892b0' }}>Supplied</div><div style={{ color: '#22c55e', fontSize: '18px', fontWeight: 'bold' }}>{formatAmount(userInfo.supplied)} MSG</div></div>
<div><div style={{ fontSize: '12px', color: '#8892b0' }}>Borrowed</div><div style={{ color: '#f97316', fontSize: '18px', fontWeight: 'bold' }}>{formatAmount(userInfo.borrowed)} MSG</div></div>
<div><div style={{ fontSize: '12px', color: '#8892b0' }}>Collateral</div><div style={{ color: userInfo.can_withdraw ? '#22c55e' : '#eab308', fontSize: '18px', fontWeight: 'bold' }}>{formatAmount(userInfo.collateral)} MSG</div></div>
<div>
<div style={{ fontSize: '12px', color: '#8892b0' }}>Health Factor</div>
<div style={{ color: hf.color, fontSize: '18px', fontWeight: 'bold' }}>{hf.value}</div>
</div>
</div>
<div style={{ width: '100%', height: '6px', background: '#0a0a1a', borderRadius: '3px', overflow: 'hidden', marginBottom: '16px' }}>
<div style={{ width: `${Math.min(parseFloat(userInfo.health_factor) * 50, 100)}%`, height: '100%', background: hf.color, borderRadius: '3px', transition: 'width 0.3s' }} />
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={onSupply} style={miniBtn}>Supply</button>
<button onClick={onWithdraw} disabled={!userInfo.can_withdraw} style={miniBtn}>Withdraw</button>
</div>
</div>
);
};
const miniBtn: React.CSSProperties = {
padding: '6px 12px', borderRadius: '6px', border: '1px solid #16213e',
background: '#16213e', color: '#64ffda', cursor: 'pointer', fontSize: '12px'
};
export default PositionCard;
6.15 src/components/Dashboard.tsx
import React, { useState, useEffect } from 'react';
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { NetworkConfig } from '../types';
import { useContract } from '../hooks/useContract';
import { usePoolData } from '../hooks/usePoolData';
import { useUserInfo } from '../hooks/useUserInfo';
import { createSigningClient, getKeplrSigner } from '../utils/cosmjs';
import PoolStats from './PoolStats';
import InterestRateChart from './InterestRateChart';
import SupplyPanel from './SupplyPanel';
import BorrowPanel from './BorrowPanel';
import PositionCard from './PositionCard';
const defaultConfig: NetworkConfig = {
chainId: 'msg-chain-1', rpcEndpoint: 'http://localhost:26657', bech32Prefix: 'msg',
lendingContract: 'msg1...', oracleContract: 'msg1...', nativeDenom: 'umsg', nativeDecimals: 18,
};
const Dashboard: React.FC = () => {
const [config] = useState<NetworkConfig>(defaultConfig);
const [userAddress, setUserAddress] = useState<string | null>(null);
const [signingClient, setSigningClient] = useState<SigningCosmWasmClient | null>(null);
const [walletError, setWalletError] = useState<string | null>(null);
const { client, contract, connected, error: connError, refreshClient } = useContract(config);
const { poolInfo, ratePoints, loading: poolLoading, error: poolError, refetch: refetchPool } = usePoolData(contract);
const { userInfo, loading: userLoading, error: userError, refetch: refetchUser } = useUserInfo(contract, userAddress);
const connectWallet = async () => {
try {
const signer = await getKeplrSigner(config);
const accounts = await signer.getAccounts();
setUserAddress(accounts[0].address);
const sc = await createSigningClient(signer, config);
setSigningClient(sc);
setWalletError(null);
} catch (err: any) { setWalletError(err.message); }
};
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '24px', fontFamily: "'Inter', sans-serif", background: '#0a0a1a', minHeight: '100vh', color: '#ccd6f6' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '32px' }}>
<div><h1 style={{ fontSize: '24px', fontWeight: 'bold', color: '#64ffda', margin: 0 }}>MSG Lending</h1>
<p style={{ color: '#8892b0', fontSize: '12px', margin: '4px 0 0 0' }}>Compound/Aave on MSG Chain</p></div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: connected ? '#22c55e' : '#ef4444', display: 'inline-block' }} />
<span style={{ fontSize: '12px', color: '#8892b0' }}>{connected ? 'Connected' : 'Disconnected'}</span>
{!userAddress ? (
<button onClick={connectWallet} style={{ padding: '8px 16px', borderRadius: '8px', background: '#22c55e', color: '#fff', border: 'none', cursor: 'pointer', fontWeight: 'bold' }}>
Connect Wallet
</button>
) : (
<span style={{ fontSize: '12px', color: '#64ffda' }}>{userAddress.slice(0, 8)}...{userAddress.slice(-6)}</span>
)}
</div>
</header>
{walletError && <div style={{ color: '#ef4444', marginBottom: '16px' }}>{walletError}</div>}
<PositionCard userInfo={userInfo} address={userAddress} onSupply={refetchUser} onWithdraw={refetchUser} />
<PoolStats poolInfo={poolInfo} loading={poolLoading} error={poolError} />
<InterestRateChart points={ratePoints} currentUtilization={poolInfo?.utilization_rate} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<SupplyPanel contract={contract} signingClient={signingClient} userAddress={userAddress} onSuccess={() => { refetchPool(); refetchUser(); }} />
<BorrowPanel contract={contract} signingClient={signingClient} userAddress={userAddress} userInfo={userInfo} onSuccess={() => { refetchPool(); refetchUser(); }} />
</div>
</div>
);
};
export default Dashboard;
6.16 src/App.tsx
import React from 'react';
import Dashboard from './components/Dashboard';
const App: React.FC = () => <div className="App"><Dashboard /></div>;
export default App;
7. Part 6: 测试、部署与运维 (Tests, Deployment & Ops)
7.1 Rust 集成测试 — tests/integration.rs
use cosmwasm_std::{Addr, Coin, Decimal, Uint128, coins, from_binary};
use cw_multi_test::{App, ContractWrapper, Executor};
use msg_lending_protocol::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, UserInfoResponse, PoolInfoResponse};
use msg_lending_protocol::state::{Config, InterestRateModel};
fn mock_app() -> App {
App::new(|router, _api, storage| {
router.bank.init_balance(storage, &Addr::unchecked("owner"), coins(1_000_000_000_000_000_000_000_000, "umsg")).unwrap();
router.bank.init_balance(storage, &Addr::unchecked("user1"), coins(1_000_000_000_000_000_000_000, "umsg")).unwrap();
router.bank.init_balance(storage, &Addr::unchecked("user2"), coins(1_000_000_000_000_000_000_000, "umsg")).unwrap();
router.bank.init_balance(storage, &Addr::unchecked("liquidator"), coins(1_000_000_000_000_000_000_000, "umsg")).unwrap();
})
}
fn instantiate_contract(app: &mut App) -> Addr {
let contract = ContractWrapper::new(
msg_lending_protocol::contract::execute,
msg_lending_protocol::contract::instantiate,
msg_lending_protocol::contract::query,
);
let code_id = app.store_code(Box::new(contract));
let init_msg = InstantiateMsg {
owner: "owner".to_string(),
denom: "umsg".to_string(),
interest_rate_model: InterestRateModel::JumpRate {
base_rate: Decimal::from_atomics(Uint128::new(2), 3).unwrap(),
rate_slope1: Decimal::from_atomics(Uint128::new(8), 3).unwrap(),
rate_slope2: Decimal::from_atomics(Uint128::new(40), 3).unwrap(),
optimal_utilization: Decimal::percent(80),
},
reserve_factor: Decimal::percent(10),
collateral_factor: Decimal::percent(75),
close_factor: Decimal::percent(50),
oracle_address: "oracle".to_string(),
max_price_age_blocks: 360,
};
app.instantiate_contract(code_id, Addr::unchecked("owner"), &init_msg, &[], "LendingPool", None).unwrap()
}
#[test]
fn test_supply_and_pool_state() {
let mut app = mock_app();
let addr = instantiate_contract(&mut app);
let user = Addr::unchecked("user1");
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Supply { on_behalf: None }, &coins(100_000_000_000_000_000_000, "umsg")).unwrap();
let pool: PoolInfoResponse = app.wrap().query_wasm_smart(addr.clone(), &QueryMsg::GetPoolInfo {}).unwrap();
assert_eq!(pool.total_supplied.to_string(), "100000000000000000000");
let user_info: UserInfoResponse = app.wrap().query_wasm_smart(addr.clone(), &QueryMsg::GetUserInfo { address: user.to_string() }).unwrap();
assert_eq!(user_info.supplied.to_string(), "100000000000000000000");
}
#[test]
fn test_full_borrow_repay_cycle() {
let mut app = mock_app();
let addr = instantiate_contract(&mut app);
let user = Addr::unchecked("user1");
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Supply { on_behalf: None }, &coins(500_000_000_000_000_000_000, "umsg")).unwrap();
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::SetCollateral { enabled: true }, &[]).unwrap();
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Borrow { amount: "100000000000000000000".to_string() }, &[]).unwrap();
let user_info: UserInfoResponse = app.wrap().query_wasm_smart(addr.clone(), &QueryMsg::GetUserInfo { address: user.to_string() }).unwrap();
assert_eq!(user_info.supplied.to_string(), "500000000000000000000");
assert_eq!(user_info.borrowed.to_string(), "100000000000000000000");
assert!(user_info.health_factor > Decimal::one());
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Repay { borrower: None }, &coins(100_000_000_000_000_000_000, "umsg")).unwrap();
let user_info2: UserInfoResponse = app.wrap().query_wasm_smart(addr.clone(), &QueryMsg::GetUserInfo { address: user.to_string() }).unwrap();
assert_eq!(user_info2.borrowed.to_string(), "0");
}
#[test]
fn test_liquidation_flow() {
let mut app = mock_app();
let addr = instantiate_contract(&mut app);
let user = Addr::unchecked("user1");
let liq = Addr::unchecked("liquidator");
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Supply { on_behalf: None }, &coins(100_000_000_000_000_000_000, "umsg")).unwrap();
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::SetCollateral { enabled: true }, &[]).unwrap();
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Borrow { amount: "70000000000000000000".to_string() }, &[]).unwrap();
let pool: PoolInfoResponse = app.wrap().query_wasm_smart(addr.clone(), &QueryMsg::GetPoolInfo {}).unwrap();
app.update_block(|b| { b.height += pool.last_price_block + 500; b.time = b.time.plus_seconds(3600); });
let to_repay = "35000000000000000000";
app.execute_contract(liq.clone(), addr.clone(), &ExecuteMsg::Liquidate {
borrower: user.to_string(), repay_amount: to_repay.to_string(), min_collateral: "0".to_string(),
}, &coins(to_repay.parse::<u128>().unwrap(), "umsg")).unwrap();
let user_info: UserInfoResponse = app.wrap().query_wasm_smart(addr.clone(), &QueryMsg::GetUserInfo { address: user.to_string() }).unwrap();
assert_eq!(user_info.borrowed.to_string(), "35000000000000000000");
}
#[test]
fn test_interest_rate_model() {
let cfg = Config {
owner: Addr::unchecked("owner"), denom: "umsg".to_string(),
interest_rate_model: InterestRateModel::JumpRate {
base_rate: Decimal::from_atomics(Uint128::new(2), 3).unwrap(),
rate_slope1: Decimal::from_atomics(Uint128::new(8), 3).unwrap(),
rate_slope2: Decimal::from_atomics(Uint128::new(40), 3).unwrap(),
optimal_utilization: Decimal::percent(80),
},
reserve_factor: Decimal::percent(10), collateral_factor: Decimal::percent(75), close_factor: Decimal::percent(50),
oracle_address: Addr::unchecked("oracle"), max_price_age_blocks: 360,
};
let sup = cfg.calculate_supply_rate(Decimal::percent(80)).unwrap();
assert!(sup > Decimal::zero());
let br = cfg.calculate_borrow_rate(Decimal::percent(50)).unwrap();
let br2 = cfg.calculate_borrow_rate(Decimal::percent(90)).unwrap();
assert!(br2 > br);
}
#[test]
fn test_unauthorized_cannot_withdraw() {
let mut app = mock_app();
let addr = instantiate_contract(&mut app);
let user = Addr::unchecked("user1");
let attacker = Addr::unchecked("attacker");
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Supply { on_behalf: None }, &coins(100_000_000_000_000_000_000, "umsg")).unwrap();
let result = app.execute_contract(attacker.clone(), addr.clone(), &ExecuteMsg::Withdraw { amount: "50000000000000000000".to_string() }, &[]);
assert!(result.is_err());
}
#[test]
fn test_health_factor_above_one() {
let mut app = mock_app();
let addr = instantiate_contract(&mut app);
let user = Addr::unchecked("user1");
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Supply { on_behalf: None }, &coins(500_000_000_000_000_000_000, "umsg")).unwrap();
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::SetCollateral { enabled: true }, &[]).unwrap();
app.execute_contract(user.clone(), addr.clone(), &ExecuteMsg::Borrow { amount: "300000000000000000000".to_string() }, &[]).unwrap();
let ui: UserInfoResponse = app.wrap().query_wasm_smart(addr, &QueryMsg::GetUserInfo { address: user.to_string() }).unwrap();
assert!(ui.health_factor > Decimal::one());
assert!(ui.can_liquidate == false);
}
#[test]
fn test_zero_supply_rejected() {
let mut app = mock_app();
let addr = instantiate_contract(&mut app);
let result = app.execute_contract(Addr::unchecked("user1"), addr, &ExecuteMsg::Supply { on_behalf: None }, &coins(0, "umsg"));
assert!(result.is_err());
}
7.2 测试向量 (Test Vectors)
| # | 操作 | 输入 | 预期输出 | 验证点 |
|---|---|---|---|---|
| 1 | Supply 100 MSG | 100_000_000_000_000_000_000 umsg | totalSupplied += 100 MSG | 池状态正确 |
| 2 | Supply + SetCollateral + Borrow 20 MSG | 供应50, 借20 | 用户负债=20, HF≈1.875 | 借贷可用 |
| 3 | Borrow 超过抵押限额 | 供应100, CF 75%, 借80 | 拒绝交易 | 安全限额生效 |
| 4 | 部分 Repay | 借50, 还20 | 剩余负债=30 | 部分还款正确 |
| 5 | 全额 Repay | 借50, 还50 | 负债清零, 不计息 | 全款还款正确 |
| 6 | Liquidation | HF<1, 清算人还50% | 清算人获抵押物+奖金 | 清算逻辑 |
| 7 | 超过资金池流动性 Withdraw | 池子流动性不足 | 拒绝 | 流动性检查 |
| 8 | 非 Owner 配置 | 尝试更新参数 | 拒绝 | 权限控制 |
| 9 | 双用户供应/借贷 | 用户A&B | 独立记账 | 多用户隔离 |
| 10 | 价格过期 | 预言机价格>360块 | 拒绝 | 价格新鲜度 |
7.3 无模拟器的 CLI 测试脚本
#!/bin/bash
set -e
# 测试向量执行脚本 (msg-chain)
CHAIN_ID="msg-chain-1"
NODE="http://localhost:26657"
HOME="--home /root/.msg"
LENDING="msg1..."
FROM="--from validator"
GAS="--gas 500000 --gas-prices 1000000000attoMSG"
echo "=== MSG Lending Test Suite ==="
echo "1. 查询池信息"
msgd query wasm contract-state smart $LENDING '{"get_pool_info":{}}' $HOME
echo "2. 供应 100 MSG"
msgd tx wasm execute $LENDING '{"supply":{"on_behalf":null}}' --amount 100000000000000000000umsg $FROM $GAS $HOME -y
echo "3. 启用抵押"
msgd tx wasm execute $LENDING '{"set_collateral":{"enabled":true}}' $FROM $GAS $HOME -y
echo "4. 查询用户信息"
msgd query wasm contract-state smart $LENDING '{"get_user_info":{"address":"'"$(msgd keys show -a validator $HOME)"'"}}' $HOME
echo "5. 借贷 50 MSG"
msgd tx wasm execute $LENDING '{"borrow":{"amount":"50000000000000000000"}}' $FROM $GAS $HOME -y
echo "6. 查询更新后信息"
msgd query wasm contract-state smart $LENDING '{"get_user_info":{"address":"'"$(msgd keys show -a validator $HOME)"'"}}' $HOME
echo "7. 还款 25 MSG"
msgd tx wasm execute $LENDING '{"repay":{"borrower":null}}' --amount 25000000000000000000umsg $FROM $GAS $HOME -y
echo "8. 提款 10 MSG"
msgd tx wasm execute $LENDING '{"withdraw":{"amount":"10000000000000000000"}}' $FROM $GAS $HOME -y
echo "=== 完成 ==="
7.4 TypeScript E2E 测试
import { CosmWasmClient, SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing';
import { GasPrice } from '@cosmjs/stargate';
import { LendingContract } from '../src/utils/contract';
const RPC = 'http://localhost:26657';
const CHAIN_ID = 'msg-chain-1';
const MNEMONIC = '[未公开凭证]';
const DENOM = 'umsg';
const CONTRACT = 'msg1...';
describe('Lending Protocol E2E', () => {
let client: CosmWasmClient; let signingClient: SigningCosmWasmClient;
let contract: LendingContract; let user: string;
beforeAll(async () => {
client = await CosmWasmClient.connect(RPC);
const wallet = await DirectSecp256k1HdWallet.fromMnemonic(MNEMONIC, { prefix: 'msg' });
const accounts = await wallet.getAccounts(); user = accounts[0].address;
signingClient = await SigningCosmWasmClient.connectWithSigner(RPC, wallet, {
gasPrice: GasPrice.fromString(`1000000000attoMSG`),
});
contract = new LendingContract(client, CONTRACT, DENOM);
});
test('pool info returns valid data', async () => {
const info = await contract.getPoolInfo();
expect(BigInt(info.total_supplied)).toBeGreaterThanOrEqual(0n);
expect(parseFloat(info.utilization_rate)).toBeGreaterThanOrEqual(0);
});
test('user can supply and withdraw', async () => {
const hash1 = await contract.supply(signingClient, user, '100000000000000000000');
expect(hash1).toBeTruthy();
const info = await contract.getUserInfo(user);
expect(BigInt(info.supplied)).toBeGreaterThanOrEqual(BigInt('100000000000000000000'));
const hash2 = await contract.withdraw(signingClient, user, '50000000000000000000');
expect(hash2).toBeTruthy();
});
test('borrow requires collateral', async () => {
await contract.supply(signingClient, user, '200000000000000000000');
await contract.setCollateral(signingClient, user, true);
const hash = await contract.borrow(signingClient, user, '50000000000000000000');
expect(hash).toBeTruthy();
const info = await contract.getUserInfo(user);
expect(BigInt(info.borrowed)).toBeGreaterThanOrEqual(BigInt('50000000000000000000'));
await contract.repay(signingClient, user, '50000000000000000000');
});
test('cannot borrow more than limit', async () => {
await expect(contract.borrow(signingClient, user, '999999999999999999999999'))
.rejects.toThrow();
});
afterAll(async () => { client.disconnect(); });
});
7.5 Docker 部署
# docker-compose.yml
version: '3.8'
services:
msg-chain:
image: msgchain/msg:latest
ports: ["26657:26657", "1317:1317", "9090:9090"]
volumes: ["./chain-data:/root/.msg"]
command: ["msgd", "start"]
environment: [MONIKER=validator, CHAIN_ID=msg-chain-1]
healthcheck: { test: ["CMD", "curl", "-f", "http://localhost:26657/status"], interval: 10s, timeout: 5s, retries: 5 }
oracle:
build: ./oracle
ports: ["8080:8080"]
depends_on: [msg-chain]
environment: [RPC_ENDPOINT=http://msg-chain:26657, CHAIN_ID=msg-chain-1]
restart: unless-stopped
liquidator:
build: ./msg-liquidation-bot
depends_on: [msg-chain, oracle]
environment:
RPC_ENDPOINT: http://msg-chain:26657
CHAIN_ID: msg-chain-1
BECH32_PREFIX: msg
LENDING_POOL_ADDRESS: ${LENDING_ADDRESS}
ORACLE_ADDRESS: ${ORACLE_ADDRESS}
LIQUIDATOR_MNEMONIC: ${LIQUIDATOR_MNEMONIC}
AGENT_API_URL: http://oracle:8080
SCAN_INTERVAL_MS: "25000"
HEALTH_FACTOR_THRESHOLD: "0.95"
restart: unless-stopped
frontend:
build: ./msg-lending-frontend
ports: ["3000:3000"]
depends_on: [msg-chain]
environment: [REACT_APP_RPC=http://msg-chain:26657, REACT_APP_CHAIN_ID=msg-chain-1]
7.6 合约部署脚本
#!/bin/bash
set -e
CHAIN_ID="msg-chain-1"
NODE="http://localhost:26657"
FROM="--from deployer"
GAS="--gas 1000000 --gas-prices 1000000000attoMSG -y"
HOME="--home /root/.msg"
echo "编译合约"
cd msg-lending-protocol
RUSTFLAGS='-C link-arg=-s' cargo wasm
wasm-opt -Os target/wasm32-unknown-unknown/release/msg_lending_protocol.wasm -o contract.wasm
echo "上传合约"
RES=$(msgd tx wasm store contract.wasm $FROM $GAS $HOME --chain-id $CHAIN_ID --node $NODE --output json)
CODE_ID=$(echo $RES | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Code ID: $CODE_ID"
echo "实例化合约"
INIT_JSON='{"owner":"'$(msgd keys show -a deployer $HOME)'","denom":"umsg",''
"interest_rate_model":{"jump_rate":{"base_rate":"0.02","rate_slope1":"0.08","rate_slope2":"0.40","optimal_utilization":"0.80"}},''
"reserve_factor":"0.10","collateral_factor":"0.75","close_factor":"0.50","oracle_address":"msg1...","max_price_age_blocks":360}'
TX=$(msgd tx wasm instantiate $CODE_ID "$INIT_JSON" --label "msg-lending-pool-v1" --admin $(msgd keys show -a deployer $HOME) $FROM $GAS $HOME --chain-id $CHAIN_ID --node $NODE --output json)
CONTRACT=$(echo $TX | jq -r '.logs[0].events[] | select(.type=="instantiate") | .attributes[] | select(.key=="_contract_address") | .value')
echo "Contract Address: $CONTRACT"
echo "验证部署"
msgd query wasm contract-state smart $CONTRACT '{"get_config":{}}' $HOME --chain-id $CHAIN_ID --node $NODE
echo "部署完成: $CONTRACT"
7.7 升级合约 (Migrate)
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MigrateMsg {
pub new_config: Option<ConfigUpdate>,
pub version: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ConfigUpdate {
pub interest_rate_model: Option<InterestRateModel>,
pub reserve_factor: Option<Decimal>,
pub collateral_factor: Option<Decimal>,
pub close_factor: Option<Decimal>,
pub oracle_address: Option<String>,
pub max_price_age_blocks: Option<u64>,
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> StdResult<Response> {
cw2::set_contract_version(deps.storage, "msg-lending-protocol", &msg.version)?;
if let Some(update) = msg.new_config {
let mut cfg = STATE.load(deps.storage)?;
if let Some(irm) = update.interest_rate_model { cfg.interest_rate_model = irm; }
if let Some(rf) = update.reserve_factor { cfg.reserve_factor = rf; }
if let Some(cf) = update.collateral_factor { cfg.collateral_factor = cf; }
if let Some(cf2) = update.close_factor { cfg.close_factor = cf2; }
if let Some(oa) = update.oracle_address { cfg.oracle_address = deps.api.addr_validate(&oa)?; }
if let Some(mpa) = update.max_price_age_blocks { cfg.max_price_age_blocks = mpa; }
STATE.save(deps.storage, &cfg)?;
}
Ok(Response::new().add_attribute("action", "migrate").add_attribute("version", &msg.version))
}
7.8 运维检查清单
# 日常监控
curl -s http://localhost:26657/status | jq '.result.sync_info'
msgd query wasm contract-state smart <LENDING> '{"get_pool_info":{}}'
# 关注指标
# - total_supplied: 池子规模
# - utilization_rate: >95% 表示流动性紧张
# - health_factor: 对每个借款用户监控
# - oracle 价格更新延块数
# 告警规则
# 1. Utilization > 95% → 增加供应激励
# 2. 任何用户 HF < 1.05 → 触发清算
# 3. 价格超过 20 块未更新 → 暂停借贷
# 4. 合约余额 != 存款-借款-储备 → 异常
# 应急恢复
# 1. 暂停借贷: owner 调用 set_paused(true)
# 2. 价格下线: 手动提供价格 via oracle
# 3. 合约升级: msgd tx wasm migrate <contract> <code_id> '{"version":"2.0.0"}'
7.9 Gas 费用参考 (估算)
| 操作 | Gas 估算 | 费用 (1,000,000,000 attoMSG/gas) |
|---|---|---|
| Supply | 150,000 | 3,750 umsg |
| Withdraw | 180,000 | 4,500 umsg |
| Borrow | 200,000 | 5,000 umsg |
| Repay | 170,000 | 4,250 umsg |
| SetCollateral | 80,000 | 2,000 umsg |
| Liquidate | 250,000 | 6,250 umsg |
| 转账 (bank send) | 65,000 | 1,625 umsg |
8. 附录
A. CosmWasm 资源
| 资源 | 链接 | 用途 |
|---|---|---|
| CosmWasm 文档 | https://docs.cosmwasm.com | 合约开发 |
| cw-plus 仓库 | https://github.com/CosmWasm/cw-plus | 标准库 |
| CosmJS 文档 | https://cosmjs.tech | TypeScript SDK |
| MSG Chain | https://msgchain.org | 主网信息 |
B. 关键地址格式
Bech32 前缀: msg
测试地址: msg1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq49q6a
合约地址: msg14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sl2yn4
Hex 转换: msg -> 0x...
C. CHANGELOG
v1.0.0 (2026-01-15)
- 初始发布
- 基础供应/借贷/清算
- 跳跃利率模型
- 预言机集成
v1.1.0 (计划)
- 多抵押品支持
- 闪电贷
- 治理投票
- cw20 代币支持
本文档是 MSG Chain 借贷协议的完整实现指南,覆盖了从智能合约开发到前端集成、清算机器人运维的整个生命周期。参考 Compound v2 和 Aave v3 的设计,同时利用 CosmWasm 的灵活性进行了优化和简化。如发现错误或有改进建议,请提交 PR 或 Issue。
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
