dApp Docs/借贷协议完整实现
Development reference. Not independently verified for production.

MSG Chain 借贷协议完整实现指南

基于 CosmWasm 1.5 的 Compound/Aave 风格超额抵押借贷协议

适用链: MSG Chain (Chain ID: msg-chain-1)
原生代币: MSG (decimals: 18, CoinType: 118, Bech32: msg)
主网状态: No-Go


目录

  1. 架构概述
  2. Part 1: 借贷池合约 (Lending Pool Contract)
  3. Part 2: 利率模型 (Interest Rate Model)
  4. Part 3: 预言机集成 (Oracle Integration)
  5. Part 4: 清算机器人 (Liquidation Bot)
  6. Part 5: 前端界面 (Frontend)
  7. Part 6: 测试 (Tests)
  8. 部署与运维

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/