dApp Docs/CW20-CW721兑换池实现
Development reference. Not independently verified for production.

CW20-CW721 Token-NFT 兑换池实现指南 (MSG Chain)

目标链: msg-chain-1 | Bech32 前缀: msg | 精度: 18 decimals
主网状态: No-Go

本文档提供从零开始构建基于订单簿的 Token-NFT 兑换池的完整指南,包含合约代码、前端集成、高级功能和安全最佳实践。


目录

  1. 概述
  2. 合约实现
  3. 报价簿管理
  4. 交易市场前端
  5. 高级功能
  6. 安全
  7. 集成测试
  8. 部署与运维

1. 概述

1.1 为什么需要 Token-NFT 兑换池

在 Cosmos 生态中,CW20 代币和 CW721 NFT 是两类最基础的资产标准。然而,它们之间的原子交换缺乏原生基础设施。一个专用的兑换池合约可以解决以下问题:

1.2 应用场景

场景 描述
NFT 定价 以 CW20 代币为计价单位,任何 NFT 可挂单出售
限价订单 买家对特定 NFT 出价,卖家决定是否接受
流动性提供 LP 存入代币作为报价资金,赚取交易手续费
稀有度套利 基于稀有度评分的差异化定价策略
批量操作 一次上架多个 NFT,或对合集出价

1.3 设计选择:订单簿而非 AMM

NFT 的核心属性是非同质性——每个 NFT 都是唯一的。这与可互换代币有本质区别:

1.4 整体架构

+-------------------------------------------------------+
|                    前端 (React)                         |
|  +----------+  +-----------+  +-------------------+   |
|  | NFT 网格  |  | 报价列表   |  | 我的挂单面板      |   |
|  +----+-----+  +-----+-----+  +--------+----------+   |
|       +--------------+-----------------+               |
|                      | CosmJS                          |
+----------------------+--------------------------------+
                       |
+----------------------+--------------------------------+
|               MSG Chain (msg-chain-1)                  |
|  +-------------------+-------------------------------+ |
|  |          NFT Swap Pool 合约                         | |
|  |  +-------------+  +----------+  +-----------+     | |
|  |  | 报价簿       |  | 挂单簿    |  | 清算引擎   |     | |
|  |  +-------------+  +----------+  +-----------+     | |
|  +---------------------------------------------------+ |
|  +------------------+  +----------------------------+  |
|  |  CW20 (Token)    |  |  CW721 (NFT)               |  |
|  +------------------+  +----------------------------+  |
+-------------------------------------------------------+

1.5 MSG Chain 参数速查

参数 值
链 ID msg-chain-1
Bech32 前缀 msg
原生代币精度 18
CosmWasm 版本 1.x+
IBC 支持 是
平均出块时间 ~6s
Gas 策略 自动估算

2. 合约实现

2.1 项目初始化

cargo init --lib nft-swap-pool
cd nft-swap-pool

Cargo.toml:

[package]
name = "nft-swap-pool"
version = "0.1.0"
edition = "2021"

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

[dependencies]
cosmwasm-std = { version = "1.5", features = ["staking"] }
cosmwasm-storage = "1.5"
cosmwasm-schema = "1.5"
cw-storage-plus = "1.2"
cw2 = "1.1"
cw20 = "1.1"
cw721 = "0.18"
cw-utils = "1.0"
schemars = "0.8"
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
uint = "0.9"

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

2.2 合约入口

src/lib.rs:

pub mod contract;
pub mod state;
pub mod msg;
pub mod error;
pub mod execute;
pub mod query;
pub mod math;

#[cfg(test)]
mod tests;

2.3 消息定义

src/msg.rs:

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

// ---- 实例化消息 ----

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct InstantiateMsg {
    /// CW721 NFT 合约地址
    pub nft_contract: String,
    /// CW20 支付代币合约地址
    pub token_contract: String,
    /// 交易手续费百分比 (例如 0.02 表示 2%)
    pub fee_percent: String,
    /// 手续费接收地址
    pub fee_address: Option<String>,
}

// ---- 执行消息 ----

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
    /// 创建买入报价
    MakeOffer {
        /// 出价金额 (CW20 代币数量)
        token_amount: Uint128,
        /// 目标 NFT 的 token_id
        nft_token_id: String,
        /// 过期高度 (可选)
        expires_at: Option<u64>,
    },
    /// 接受报价 (卖家调用)
    AcceptOffer {
        /// 报价 ID
        offer_id: u64,
    },
    /// 取消报价 (买家调用)
    CancelOffer {
        /// 报价 ID
        offer_id: u64,
    },
    /// 挂单出售 NFT
    ListNft {
        /// NFT token_id
        token_id: String,
        /// 售价
        price: Uint128,
    },
    /// 购买挂单的 NFT
    BuyNft {
        /// 挂单 ID
        listing_id: u64,
    },
    /// 取消挂单 (卖家撤回)
    WithdrawNft {
        /// 挂单 ID
        listing_id: u64,
    },
    /// 更新配置 (管理员)
    UpdateConfig {
        fee_percent: Option<String>,
        fee_address: Option<String>,
    },
}

// ---- 查询消息 ----

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
    /// 获取合约配置
    GetConfig {},
    /// 获取指定报价详情
    GetOffer {
        offer_id: u64,
    },
    /// 列出所有活跃报价 (带分页)
    ListOffers {
        start_after: Option<u64>,
        limit: Option<u32>,
    },
    /// 获取指定用户的报价列表
    ListOffersByBuyer {
        buyer: String,
        start_after: Option<u64>,
        limit: Option<u32>,
    },
    /// 获取指定 NFT 的报价
    ListOffersForNft {
        nft_token_id: String,
        start_after: Option<u64>,
        limit: Option<u32>,
    },
    /// 获取挂单详情
    GetListing {
        listing_id: u64,
    },
    /// 列出所有活跃挂单 (带分页)
    ListListings {
        start_after: Option<u64>,
        limit: Option<u32>,
    },
    /// 获取卖家的挂单
    ListListingsBySeller {
        seller: String,
        start_after: Option<u64>,
        limit: Option<u32>,
    },
    /// 获取挂单数量统计
    GetStats {},
}

// ---- 查询响应 ----

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ConfigResponse {
    pub nft_contract: String,
    pub token_contract: String,
    pub fee_percent: String,
    pub fee_address: String,
    pub owner: String,
    pub offer_count: u64,
    pub listing_count: u64,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct OfferResponse {
    pub offer_id: u64,
    pub buyer: String,
    pub nft_token_id: String,
    pub token_amount: Uint128,
    pub created_at: u64,
    pub expires_at: Option<u64>,
    pub status: String, // "active", "accepted", "cancelled"
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct OfferListResponse {
    pub offers: Vec<OfferResponse>,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ListingResponse {
    pub listing_id: u64,
    pub seller: String,
    pub nft_token_id: String,
    pub price: Uint128,
    pub created_at: u64,
    pub status: String, // "active", "sold", "withdrawn"
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct ListingListResponse {
    pub listings: Vec<ListingResponse>,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct StatsResponse {
    pub active_offers: u64,
    pub active_listings: u64,
    pub total_volume: Uint128,
    pub total_fees: Uint128,
}

// ---- 迁移消息 ----

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct MigrateMsg {}

2.4 错误定义

src/error.rs:

use cosmwasm_std::{StdError, Uint128};
use thiserror::Error;

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

    #[error("Unauthorized")]
    Unauthorized {},

    #[error("Offer not found: {offer_id}")]
    OfferNotFound { offer_id: u64 },

    #[error("Listing not found: {listing_id}")]
    ListingNotFound { listing_id: u64 },

    #[error("Offer is not active")]
    OfferNotActive {},

    #[error("Listing is not active")]
    ListingNotActive {},

    #[error("Insufficient payment: required {required}, received {received}")]
    InsufficientPayment { required: Uint128, received: Uint128 },

    #[error("NFT already listed: {token_id}")]
    NftAlreadyListed { token_id: String },

    #[error("NFT not owned by seller")]
    NftNotOwned {},

    #[error("Offer expired")]
    OfferExpired {},

    #[error("Invalid fee percent: max 0.10 (10%)")]
    InvalidFee {},

    #[error("Invalid zero amount")]
    ZeroAmount {},

    #[error("Swap already executed for this offer")]
    AlreadyExecuted {},

    #[error("Reentrancy detected")]
    ReentrancyDetected {},

    #[error("Fake NFT detected: contract {contract} does not match expected {expected}")]
    FakeNftDetected { contract: String, expected: String },
}

2.5 状态存储

src/state.rs:

use cosmwasm_std::{Addr, Decimal, Uint128};
use cw_storage_plus::{Item, Map, SnapshotMap, Strategy};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uint::construct_uint;

construct_uint! {
    pub struct U256(4);
}

// ---- 合约配置 ----

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Config {
    pub nft_contract: Addr,
    pub token_contract: Addr,
    pub fee_percent: Decimal,
    pub fee_address: Addr,
    pub owner: Addr,
    pub offer_count: u64,
    pub listing_count: u64,
    pub total_volume: Uint128,
    pub total_fees: Uint128,
}

pub const CONFIG: Item<Config> = Item::new("config");

// ---- 报价状态 ----

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum OfferStatus {
    Active,
    Accepted,
    Cancelled,
}

impl OfferStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            OfferStatus::Active => "active",
            OfferStatus::Accepted => "accepted",
            OfferStatus::Cancelled => "cancelled",
        }
    }

    pub fn from_str(s: &str) -> Option<Self> {
        match s {
            "active" => Some(OfferStatus::Active),
            "accepted" => Some(OfferStatus::Accepted),
            "cancelled" => Some(OfferStatus::Cancelled),
            _ => None,
        }
    }
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Offer {
    pub offer_id: u64,
    pub buyer: Addr,
    pub nft_token_id: String,
    pub token_amount: Uint128,
    pub created_at: u64,
    pub expires_at: Option<u64>,
    pub status: OfferStatus,
}

pub const OFFERS: Map<u64, Offer> = Map::new("offers");
pub const OFFERS_BY_BUYER: Map<(Addr, u64), bool> = Map::new("offers_by_buyer");
pub const OFFERS_BY_NFT: Map<(String, u64), bool> = Map::new("offers_by_nft");

// ---- 挂单状态 ----

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub enum ListingStatus {
    Active,
    Sold,
    Withdrawn,
}

impl ListingStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            ListingStatus::Active => "active",
            ListingStatus::Sold => "sold",
            ListingStatus::Withdrawn => "withdrawn",
        }
    }

    pub fn from_str(s: &str) -> Option<Self> {
        match s {
            "active" => Some(ListingStatus::Active),
            "sold" => Some(ListingStatus::Sold),
            "withdrawn" => Some(ListingStatus::Withdrawn),
            _ => None,
        }
    }
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct Listing {
    pub listing_id: u64,
    pub seller: Addr,
    pub nft_token_id: String,
    pub price: Uint128,
    pub created_at: u64,
    pub status: ListingStatus,
}

pub const LISTINGS: Map<u64, Listing> = Map::new("listings");
pub const LISTINGS_BY_SELLER: Map<(Addr, u64), bool> = Map::new("listings_by_seller");
pub const LISTINGS_BY_NFT: Map<String, u64> = Map::new("listings_by_nft");

// ---- 防止重入 ----

pub const LOCK: Item<bool> = Item::new("lock");

// ---- 辅助函数 ----

pub fn calc_fee(amount: Uint128, fee_percent: Decimal) -> Uint128 {
    amount * fee_percent
}

pub fn calc_net_amount(amount: Uint128, fee_percent: Decimal) -> Uint128 {
    amount - calc_fee(amount, fee_percent)
}

2.6 合约入口点

src/contract.rs:

use cosmwasm_std::{
    entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult,
};
use cw2::set_contract_version;

use crate::error::ContractError;
use crate::execute;
use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg};
use crate::query;
use crate::state::{Config, CONFIG};

const CONTRACT_NAME: &str = "nft-swap-pool";
const CONTRACT_VERSION: &str = "0.1.0";

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: InstantiateMsg,
) -> Result<Response, ContractError> {
    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;

    let fee_percent: Decimal = msg
        .fee_percent
        .parse()
        .map_err(|_| ContractError::InvalidFee {})?;

    if fee_percent > Decimal::percent(10) {
        return Err(ContractError::InvalidFee {});
    }

    let fee_address = match msg.fee_address {
        Some(addr) => deps.api.addr_validate(&addr)?,
        None => info.sender.clone(),
    };

    let config = Config {
        nft_contract: deps.api.addr_validate(&msg.nft_contract)?,
        token_contract: deps.api.addr_validate(&msg.token_contract)?,
        fee_percent,
        fee_address,
        owner: info.sender,
        offer_count: 0,
        listing_count: 0,
        total_volume: Uint128::zero(),
        total_fees: Uint128::zero(),
    };

    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new()
        .add_attribute("action", "instantiate")
        .add_attribute("nft_contract", msg.nft_contract)
        .add_attribute("token_contract", msg.token_contract)
        .add_attribute("fee_percent", msg.fee_percent))
}

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> Result<Response, ContractError> {
    match msg {
        ExecuteMsg::MakeOffer { token_amount, nft_token_id, expires_at } => {
            execute::make_offer(deps, env, info, token_amount, nft_token_id, expires_at)
        }
        ExecuteMsg::AcceptOffer { offer_id } => execute::accept_offer(deps, env, info, offer_id),
        ExecuteMsg::CancelOffer { offer_id } => execute::cancel_offer(deps, env, info, offer_id),
        ExecuteMsg::ListNft { token_id, price } => {
            execute::list_nft(deps, env, info, token_id, price)
        }
        ExecuteMsg::BuyNft { listing_id } => execute::buy_nft(deps, env, info, listing_id),
        ExecuteMsg::WithdrawNft { listing_id } => {
            execute::withdraw_nft(deps, env, info, listing_id)
        }
        ExecuteMsg::UpdateConfig { fee_percent, fee_address } => {
            execute::update_config(deps, env, info, fee_percent, fee_address)
        }
    }
}

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
    match msg {
        QueryMsg::GetConfig {} => to_json_binary(&query::get_config(deps)?),
        QueryMsg::GetOffer { offer_id } => to_json_binary(&query::get_offer(deps, offer_id)?),
        QueryMsg::ListOffers { start_after, limit } => {
            to_json_binary(&query::list_offers(deps, start_after, limit)?)
        }
        QueryMsg::ListOffersByBuyer { buyer, start_after, limit } => {
            to_json_binary(&query::list_offers_by_buyer(deps, buyer, start_after, limit)?)
        }
        QueryMsg::ListOffersForNft { nft_token_id, start_after, limit } => {
            to_json_binary(&query::list_offers_for_nft(deps, nft_token_id, start_after, limit)?)
        }
        QueryMsg::GetListing { listing_id } => {
            to_json_binary(&query::get_listing(deps, listing_id)?)
        }
        QueryMsg::ListListings { start_after, limit } => {
            to_json_binary(&query::list_listings(deps, start_after, limit)?)
        }
        QueryMsg::ListListingsBySeller { seller, start_after, limit } => {
            to_json_binary(&query::list_listings_by_seller(deps, seller, start_after, limit)?)
        }
        QueryMsg::GetStats {} => to_json_binary(&query::get_stats(deps)?),
    }
}

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result<Response, ContractError> {
    Ok(Response::new())
}

2.7 执行逻辑

src/execute.rs:

use cosmwasm_std::{
    to_json_binary, Addr, CosmosMsg, Decimal, DepsMut, Env, MessageInfo, Response, Uint128, WasmMsg,
};
use cw20::Cw20ExecuteMsg;
use cw721::Cw721ExecuteMsg;

use crate::error::ContractError;
use crate::state::{
    calc_fee, calc_net_amount, Listing, ListingStatus, Offer, OfferStatus, Config, CONFIG,
    LISTINGS, LISTINGS_BY_NFT, LISTINGS_BY_SELLER, LOCK, OFFERS, OFFERS_BY_BUYER, OFFERS_BY_NFT,
};

// ---- 创建买入报价 ----

pub fn make_offer(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_amount: Uint128,
    nft_token_id: String,
    expires_at: Option<u64>,
) -> Result<Response, ContractError> {
    if token_amount.is_zero() {
        return Err(ContractError::ZeroAmount {});
    }

    if let Some(exp) = expires_at {
        if exp <= env.block.height {
            return Err(ContractError::OfferExpired {});
        }
    }

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

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

    config.offer_count += 1;
    let offer_id = config.offer_count;

    let offer = Offer {
        offer_id,
        buyer: info.sender.clone(),
        nft_token_id: nft_token_id.clone(),
        token_amount,
        created_at: env.block.height,
        expires_at,
        status: OfferStatus::Active,
    };

    OFFERS.save(deps.storage, offer_id, &offer)?;
    OFFERS_BY_BUYER.save(deps.storage, (info.sender.clone(), offer_id), &true)?;
    OFFERS_BY_NFT.save(deps.storage, (nft_token_id.clone(), offer_id), &true)?;
    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("action", "make_offer")
        .add_attribute("offer_id", offer_id.to_string())
        .add_attribute("buyer", info.sender.as_str())
        .add_attribute("nft_token_id", nft_token_id)
        .add_attribute("token_amount", token_amount.to_string()))
}

// ---- 接受报价 ----

pub fn accept_offer(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    offer_id: u64,
) -> Result<Response, ContractError> {
    assert_no_lock(deps.storage)?;
    set_lock(deps.storage, true)?;

    let mut offer = OFFERS
        .load(deps.storage, offer_id)
        .map_err(|_| ContractError::OfferNotFound { offer_id })?;

    if offer.status != OfferStatus::Active {
        return Err(ContractError::OfferNotActive {});
    }

    if let Some(exp) = offer.expires_at {
        if env.block.height > exp {
            offer.status = OfferStatus::Cancelled;
            OFFERS.save(deps.storage, offer_id, &offer)?;
            set_lock(deps.storage, false)?;
            return Err(ContractError::OfferExpired {});
        }
    }

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

    let nft_owner: cw721::OwnerOfResponse = deps.querier.query_wasm_smart(
        config.nft_contract.to_string(),
        &cw721::Cw721QueryMsg::OwnerOf {
            token_id: offer.nft_token_id.clone(),
            include_expired: None,
        },
    )?;

    if nft_owner.owner != info.sender.to_string() {
        return Err(ContractError::NftNotOwned {});
    }

    let fee = calc_fee(offer.token_amount, config.fee_percent);
    let net_amount = calc_net_amount(offer.token_amount, config.fee_percent);

    let mut config = config;
    config.total_volume = config.total_volume.checked_add(offer.token_amount)?;
    config.total_fees = config.total_fees.checked_add(fee)?;
    CONFIG.save(deps.storage, &config)?;

    offer.status = OfferStatus::Accepted;
    OFFERS.save(deps.storage, offer_id, &offer)?;

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

    let pay_fee_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token_contract.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: config.fee_address.to_string(),
            amount: fee,
        })?,
        funds: vec![],
    });

    let transfer_nft_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.nft_contract.to_string(),
        msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
            recipient: offer.buyer.to_string(),
            token_id: offer.nft_token_id.clone(),
        })?,
        funds: vec![],
    });

    set_lock(deps.storage, false)?;

    Ok(Response::new()
        .add_message(pay_seller_msg)
        .add_message(pay_fee_msg)
        .add_message(transfer_nft_msg)
        .add_attribute("action", "accept_offer")
        .add_attribute("offer_id", offer_id.to_string())
        .add_attribute("buyer", offer.buyer.as_str())
        .add_attribute("seller", info.sender.as_str())
        .add_attribute("nft_token_id", offer.nft_token_id)
        .add_attribute("amount", offer.token_amount.to_string())
        .add_attribute("fee", fee.to_string()))
}

2.7 执行逻辑 (续)

// ---- 取消报价 ----

pub fn cancel_offer(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    offer_id: u64,
) -> Result<Response, ContractError> {
    let mut offer = OFFERS
        .load(deps.storage, offer_id)
        .map_err(|_| ContractError::OfferNotFound { offer_id })?;

    if offer.buyer != info.sender {
        return Err(ContractError::Unauthorized {});
    }

    if offer.status != OfferStatus::Active {
        return Err(ContractError::OfferNotActive {});
    }

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

    offer.status = OfferStatus::Cancelled;
    OFFERS.save(deps.storage, offer_id, &offer)?;

    let refund_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token_contract.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: info.sender.to_string(),
            amount: offer.token_amount,
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(refund_msg)
        .add_attribute("action", "cancel_offer")
        .add_attribute("offer_id", offer_id.to_string())
        .add_attribute("buyer", info.sender.as_str())
        .add_attribute("refund_amount", offer.token_amount.to_string()))
}

// ---- 挂单出售 NFT ----

pub fn list_nft(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    price: Uint128,
) -> Result<Response, ContractError> {
    if price.is_zero() {
        return Err(ContractError::ZeroAmount {});
    }

    if LISTINGS_BY_NFT.has(deps.storage, token_id.clone()) {
        return Err(ContractError::NftAlreadyListed {
            token_id: token_id.clone(),
        });
    }

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

    let owner_info: cw721::OwnerOfResponse = deps.querier.query_wasm_smart(
        config.nft_contract.to_string(),
        &cw721::Cw721QueryMsg::OwnerOf {
            token_id: token_id.clone(),
            include_expired: None,
        },
    )?;

    if owner_info.owner != info.sender.to_string() {
        return Err(ContractError::NftNotOwned {});
    }

    let transfer_nft_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.nft_contract.to_string(),
        msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
            recipient: env.contract.address.to_string(),
            token_id: token_id.clone(),
        })?,
        funds: vec![],
    });

    let mut config = config;
    config.listing_count += 1;
    let listing_id = config.listing_count;

    let listing = Listing {
        listing_id,
        seller: info.sender.clone(),
        nft_token_id: token_id.clone(),
        price,
        created_at: env.block.height,
        status: ListingStatus::Active,
    };

    LISTINGS.save(deps.storage, listing_id, &listing)?;
    LISTINGS_BY_SELLER.save(deps.storage, (info.sender.clone(), listing_id), &true)?;
    LISTINGS_BY_NFT.save(deps.storage, token_id.clone(), &listing_id)?;
    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new()
        .add_message(transfer_nft_msg)
        .add_attribute("action", "list_nft")
        .add_attribute("listing_id", listing_id.to_string())
        .add_attribute("seller", info.sender.as_str())
        .add_attribute("nft_token_id", token_id)
        .add_attribute("price", price.to_string()))
}

// ---- 购买挂单 NFT ----

pub fn buy_nft(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    listing_id: u64,
) -> Result<Response, ContractError> {
    assert_no_lock(deps.storage)?;
    set_lock(deps.storage, true)?;

    let mut listing = LISTINGS
        .load(deps.storage, listing_id)
        .map_err(|_| ContractError::ListingNotFound { listing_id })?;

    if listing.status != ListingStatus::Active {
        return Err(ContractError::ListingNotActive {});
    }

    if listing.seller == info.sender {
        return Err(ContractError::Unauthorized {});
    }

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

    let fee = calc_fee(listing.price, config.fee_percent);
    let net_amount = calc_net_amount(listing.price, config.fee_percent);

    let mut config = config;
    config.total_volume = config.total_volume.checked_add(listing.price)?;
    config.total_fees = config.total_fees.checked_add(fee)?;
    CONFIG.save(deps.storage, &config)?;

    listing.status = ListingStatus::Sold;
    LISTINGS.save(deps.storage, listing_id, &listing)?;

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

    let pay_seller_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token_contract.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: listing.seller.to_string(),
            amount: net_amount,
        })?,
        funds: vec![],
    });

    let pay_fee_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.token_contract.to_string(),
        msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
            recipient: config.fee_address.to_string(),
            amount: fee,
        })?,
        funds: vec![],
    });

    let transfer_nft_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.nft_contract.to_string(),
        msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
            recipient: info.sender.to_string(),
            token_id: listing.nft_token_id.clone(),
        })?,
        funds: vec![],
    });

    LISTINGS_BY_NFT.remove(deps.storage, listing.nft_token_id.clone());
    set_lock(deps.storage, false)?;

    Ok(Response::new()
        .add_message(transfer_from_msg)
        .add_message(pay_seller_msg)
        .add_message(pay_fee_msg)
        .add_message(transfer_nft_msg)
        .add_attribute("action", "buy_nft")
        .add_attribute("listing_id", listing_id.to_string())
        .add_attribute("buyer", info.sender.as_str())
        .add_attribute("seller", listing.seller.as_str())
        .add_attribute("nft_token_id", listing.nft_token_id)
        .add_attribute("price", listing.price.to_string())
        .add_attribute("fee", fee.to_string()))
}
// ---- 撤回挂单 ----

pub fn withdraw_nft(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    listing_id: u64,
) -> Result<Response, ContractError> {
    let mut listing = LISTINGS
        .load(deps.storage, listing_id)
        .map_err(|_| ContractError::ListingNotFound { listing_id })?;

    if listing.seller != info.sender {
        return Err(ContractError::Unauthorized {});
    }

    if listing.status != ListingStatus::Active {
        return Err(ContractError::ListingNotActive {});
    }

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

    listing.status = ListingStatus::Withdrawn;
    LISTINGS.save(deps.storage, listing_id, &listing)?;
    LISTINGS_BY_NFT.remove(deps.storage, listing.nft_token_id.clone());

    let refund_nft_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: config.nft_contract.to_string(),
        msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
            recipient: info.sender.to_string(),
            token_id: listing.nft_token_id.clone(),
        })?,
        funds: vec![],
    });

    Ok(Response::new()
        .add_message(refund_nft_msg)
        .add_attribute("action", "withdraw_nft")
        .add_attribute("listing_id", listing_id.to_string())
        .add_attribute("seller", info.sender.as_str())
        .add_attribute("nft_token_id", listing.nft_token_id))
}

// ---- 更新配置 ----

pub fn update_config(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    fee_percent: Option<String>,
    fee_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(fp) = fee_percent {
        let new_fee: Decimal = fp
            .parse()
            .map_err(|_| ContractError::InvalidFee {})?;
        if new_fee > Decimal::percent(10) {
            return Err(ContractError::InvalidFee {});
        }
        config.fee_percent = new_fee;
    }

    if let Some(fa) = fee_address {
        config.fee_address = deps.api.addr_validate(&fa)?;
    }

    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new()
        .add_attribute("action", "update_config")
        .add_attribute("fee_percent", config.fee_percent.to_string())
        .add_attribute("fee_address", config.fee_address.as_str()))
}

// ---- 防重入 ----

fn assert_no_lock(storage: &mut dyn cosmwasm_std::Storage) -> Result<(), ContractError> {
    if LOCK.load(storage).unwrap_or(false) {
        return Err(ContractError::ReentrancyDetected {});
    }
    Ok(())
}

fn set_lock(storage: &mut dyn cosmwasm_std::Storage, locked: bool) -> Result<(), ContractError> {
    LOCK.save(storage, &locked)?;
    Ok(())
}

2.8 查询逻辑

src/query.rs:

use cosmwasm_std::{Deps, Order, StdResult, Uint128};
use crate::msg::{
    ConfigResponse, ListingListResponse, ListingResponse, OfferListResponse, OfferResponse,
    StatsResponse,
};
use crate::state::{
    ListingStatus, OfferStatus, Config, CONFIG, LISTINGS, LISTINGS_BY_SELLER, OFFERS,
    OFFERS_BY_BUYER, OFFERS_BY_NFT,
};

const DEFAULT_LIMIT: u32 = 30;
const MAX_LIMIT: u32 = 100;

pub fn get_config(deps: Deps) -> StdResult<ConfigResponse> {
    let config = CONFIG.load(deps.storage)?;
    Ok(ConfigResponse {
        nft_contract: config.nft_contract.to_string(),
        token_contract: config.token_contract.to_string(),
        fee_percent: config.fee_percent.to_string(),
        fee_address: config.fee_address.to_string(),
        owner: config.owner.to_string(),
        offer_count: config.offer_count,
        listing_count: config.listing_count,
    })
}

pub fn get_offer(deps: Deps, offer_id: u64) -> StdResult<OfferResponse> {
    let offer = OFFERS.load(deps.storage, offer_id)?;
    Ok(OfferResponse {
        offer_id: offer.offer_id,
        buyer: offer.buyer.to_string(),
        nft_token_id: offer.nft_token_id,
        token_amount: offer.token_amount,
        created_at: offer.created_at,
        expires_at: offer.expires_at,
        status: offer.status.as_str().to_string(),
    })
}

pub fn list_offers(
    deps: Deps,
    start_after: Option<u64>,
    limit: Option<u32>,
) -> StdResult<OfferListResponse> {
    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
    let start = start_after.unwrap_or(0);

    let offers: StdResult<Vec<_>> = OFFERS
        .range(deps.storage, Some(start + 1), None, Order::Ascending)
        .take(limit as usize)
        .filter(|item| {
            if let Ok((_, offer)) = item {
                offer.status == OfferStatus::Active
            } else { true }
        })
        .map(|item| {
            let (_, offer) = item?;
            Ok(OfferResponse {
                offer_id: offer.offer_id,
                buyer: offer.buyer.to_string(),
                nft_token_id: offer.nft_token_id,
                token_amount: offer.token_amount,
                created_at: offer.created_at,
                expires_at: offer.expires_at,
                status: offer.status.as_str().to_string(),
            })
        })
        .collect();

    Ok(OfferListResponse { offers: offers? })
}

pub fn list_offers_by_buyer(
    deps: Deps,
    buyer: String,
    start_after: Option<u64>,
    limit: Option<u32>,
) -> StdResult<OfferListResponse> {
    let buyer_addr = deps.api.addr_validate(&buyer)?;
    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
    let start = start_after.unwrap_or(0);

    let offers: StdResult<Vec<_>> = OFFERS_BY_BUYER
        .prefix(buyer_addr)
        .range(deps.storage, Some(start + 1), None, Order::Ascending)
        .take(limit as usize)
        .map(|item| {
            let (offer_id, _) = item?;
            let offer = OFFERS.load(deps.storage, offer_id)?;
            Ok(OfferResponse {
                offer_id: offer.offer_id,
                buyer: offer.buyer.to_string(),
                nft_token_id: offer.nft_token_id,
                token_amount: offer.token_amount,
                created_at: offer.created_at,
                expires_at: offer.expires_at,
                status: offer.status.as_str().to_string(),
            })
        })
        .collect();

    Ok(OfferListResponse { offers: offers? })
}

pub fn list_offers_for_nft(
    deps: Deps,
    nft_token_id: String,
    start_after: Option<u64>,
    limit: Option<u32>,
) -> StdResult<OfferListResponse> {
    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
    let start = start_after.unwrap_or(0);

    let offers: StdResult<Vec<_>> = OFFERS_BY_NFT
        .prefix(nft_token_id)
        .range(deps.storage, Some(start + 1), None, Order::Descending)
        .take(limit as usize)
        .map(|item| {
            let (offer_id, _) = item?;
            let offer = OFFERS.load(deps.storage, offer_id)?;
            Ok(OfferResponse {
                offer_id: offer.offer_id,
                buyer: offer.buyer.to_string(),
                nft_token_id: offer.nft_token_id,
                token_amount: offer.token_amount,
                created_at: offer.created_at,
                expires_at: offer.expires_at,
                status: offer.status.as_str().to_string(),
            })
        })
        .collect();

    Ok(OfferListResponse { offers: offers? })
}
pub fn get_listing(deps: Deps, listing_id: u64) -> StdResult<ListingResponse> {
    let listing = LISTINGS.load(deps.storage, listing_id)?;
    Ok(ListingResponse {
        listing_id: listing.listing_id,
        seller: listing.seller.to_string(),
        nft_token_id: listing.nft_token_id,
        price: listing.price,
        created_at: listing.created_at,
        status: listing.status.as_str().to_string(),
    })
}

pub fn list_listings(
    deps: Deps,
    start_after: Option<u64>,
    limit: Option<u32>,
) -> StdResult<ListingListResponse> {
    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
    let start = start_after.unwrap_or(0);

    let listings: StdResult<Vec<_>> = LISTINGS
        .range(deps.storage, Some(start + 1), None, Order::Ascending)
        .take(limit as usize)
        .filter(|item| {
            if let Ok((_, listing)) = item {
                listing.status == ListingStatus::Active
            } else { true }
        })
        .map(|item| {
            let (_, listing) = item?;
            Ok(ListingResponse {
                listing_id: listing.listing_id,
                seller: listing.seller.to_string(),
                nft_token_id: listing.nft_token_id,
                price: listing.price,
                created_at: listing.created_at,
                status: listing.status.as_str().to_string(),
            })
        })
        .collect();

    Ok(ListingListResponse { listings: listings? })
}

pub fn list_listings_by_seller(
    deps: Deps,
    seller: String,
    start_after: Option<u64>,
    limit: Option<u32>,
) -> StdResult<ListingListResponse> {
    let seller_addr = deps.api.addr_validate(&seller)?;
    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
    let start = start_after.unwrap_or(0);

    let listings: StdResult<Vec<_>> = LISTINGS_BY_SELLER
        .prefix(seller_addr)
        .range(deps.storage, Some(start + 1), None, Order::Ascending)
        .take(limit as usize)
        .map(|item| {
            let (listing_id, _) = item?;
            let listing = LISTINGS.load(deps.storage, listing_id)?;
            Ok(ListingResponse {
                listing_id: listing.listing_id,
                seller: listing.seller.to_string(),
                nft_token_id: listing.nft_token_id,
                price: listing.price,
                created_at: listing.created_at,
                status: listing.status.as_str().to_string(),
            })
        })
        .collect();

    Ok(ListingListResponse { listings: listings? })
}

pub fn get_stats(deps: Deps) -> StdResult<StatsResponse> {
    let config = CONFIG.load(deps.storage)?;

    let active_offers = OFFERS
        .range(deps.storage, None, None, Order::Ascending)
        .filter(|item| {
            if let Ok((_, offer)) = item {
                offer.status == OfferStatus::Active
            } else { false }
        })
        .count() as u64;

    let active_listings = LISTINGS
        .range(deps.storage, None, None, Order::Ascending)
        .filter(|item| {
            if let Ok((_, listing)) = item {
                listing.status == ListingStatus::Active
            } else { false }
        })
        .count() as u64;

    Ok(StatsResponse {
        active_offers,
        active_listings,
        total_volume: config.total_volume,
        total_fees: config.total_fees,
    })
}

2.9 数学工具

src/math.rs:

use cosmwasm_std::{Decimal, Uint128};
use uint::construct_uint;

construct_uint! {
    pub struct U256(4);
}

pub fn decimal_multiplication(a: Uint128, b: Decimal) -> Uint128 {
    let a_u256 = U256::from(a.u128());
    let b_u256 = U256::from(b.atomics().u128());
    let denominator = U256::from(1_000_000_000_000_000_000u128);
    let result = a_u256 * b_u256 / denominator;
    Uint128::new(result.as_u128())
}

pub fn percent_of(value: Uint128, percent: u64) -> Uint128 {
    let decimal = Decimal::percent(percent);
    decimal_multiplication(value, decimal)
}

pub fn integer_sqrt(value: Uint128) -> Uint128 {
    if value.is_zero() { return Uint128::zero(); }
    let mut x = value;
    let mut y = (x + Uint128::one()) / Uint128::new(2);
    while y < x {
        x = y;
        let div = value / x;
        y = (x + div) / Uint128::new(2);
    }
    x
}

pub fn format_token_amount(amount: Uint128, decimals: u8) -> String {
    let divisor = Uint128::new(10u128.pow(decimals as u32));
    let integer_part = amount / divisor;
    let fractional_part = amount % divisor;
    if fractional_part.is_zero() {
        integer_part.to_string()
    } else {
        let frac_str = format!("{:0width$}", fractional_part.u128(), width = decimals as usize);
        let trimmed = frac_str.trim_end_matches('0');
        format!("{}.{}", integer_part, trimmed)
    }
}

pub fn parse_token_amount(amount_str: &str, decimals: u8) -> Result<Uint128, String> {
    let parts: Vec<&str> = amount_str.split('.').collect();
    match parts.len() {
        1 => {
            let int_val: u128 = parts[0].parse().map_err(|_| "Invalid integer")?;
            Ok(Uint128::new(int_val * 10u128.pow(decimals as u32)))
        }
        2 => {
            let int_val: u128 = parts[0].parse().map_err(|_| "Invalid integer")?;
            let frac_str = parts[1];
            if frac_str.len() > decimals as usize {
                return Err("Too many decimal places".to_string());
            }
            let frac_val: u128 = frac_str.parse().map_err(|_| "Invalid fractional")?;
            let padded = frac_val * 10u128.pow((decimals as u32) - frac_str.len() as u32);
            Ok(Uint128::new(int_val * 10u128.pow(decimals as u32) + padded))
        }
        _ => Err("Invalid amount format".to_string()),
    }
}

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

    #[test]
    fn test_format_token_amount() {
        let amount = Uint128::new(1_500_000_000_000_000_000u128);
        assert_eq!(format_token_amount(amount, 18), "1.5");
    }

    #[test]
    fn test_parse_token_amount() {
        let result = parse_token_amount("1.5", 18).unwrap();
        assert_eq!(result, Uint128::new(1_500_000_000_000_000_000u128));
    }

    #[test]
    fn test_integer_sqrt() {
        let val = Uint128::new(144);
        assert_eq!(integer_sqrt(val), Uint128::new(12));
    }

    #[test]
    fn test_percent_of() {
        let val = Uint128::new(1000);
        assert_eq!(percent_of(val, 50), Uint128::new(500));
        assert_eq!(percent_of(val, 100), Uint128::new(1000));
        assert_eq!(percent_of(val, 0), Uint128::new(0));
    }
}

3. 报价簿管理

3.1 数据结构设计

报价簿是本合约的核心数据结构。我们维护两种订单类型:

报价 (Offer) — 买家锁定 CW20 代币,表达对特定 NFT 的购买意向:

offer_id: u64           -- 全局唯一递增 ID
buyer: Addr             -- 买家地址
nft_token_id: String    -- 目标 NFT
token_amount: Uint128   -- 出价金额 (CW20)
created_at: u64         -- 创建高度
expires_at: Option<u64> -- 过期高度 (None=永不过期)
status: OfferStatus     -- Active | Accepted | Cancelled

挂单 (Listing) — 卖家将 NFT 存入合约,标价出售:

listing_id: u64         -- 全局唯一递增 ID
seller: Addr            -- 卖家地址
nft_token_id: String    -- NFT token_id
price: Uint128          -- 售价
created_at: u64         -- 创建高度
status: ListingStatus   -- Active | Sold | Withdrawn

3.2 索引设计

为了实现高效查询,使用以下复合索引:

// 按买家查询所有报价
OFFERS_BY_BUYER: Map<(Addr, u64), bool>
// 例如: ("msg1...", 42) -> true

// 按 NFT 查询所有报价 (价格排序)
OFFERS_BY_NFT: Map<(String, u64), bool>
// 例如: ("token_123", 42) -> true

// 按卖家查询所有挂单
LISTINGS_BY_SELLER: Map<(Addr, u64), bool>
// 例如: ("msg1...", 7) -> true

// 按 NFT 查询挂单 (1:1 关系)
LISTINGS_BY_NFT: Map<String, u64>
// 例如: "token_123" -> 7

3.3 撮合引擎

撮合逻辑分为两种路径:

路径 A: 报价被接受 (Offer -> Accept)

1. 卖家验证
2. 报价状态检查
3. 合约锁住代币 -> 转给卖家 (扣手续费)
4. 卖家转 NFT -> 给买家
5. 报价标记为 Accepted

路径 B: 挂单被购买 (Listing -> Buy)

1. 买家验证
2. 挂单状态检查
3. 买家 TransferFrom 代币到合约
4. 合约转代币给卖家 (扣手续费)
5. 合约转 NFT 给买家
6. 挂单标记为 Sold

3.4 价格排序

按价格升序/降序遍历 OFFERS_BY_NFT:

pub fn get_highest_offer_for_nft(
    deps: Deps,
    nft_token_id: String,
) -> StdResult<Option<(u64, Uint128)>> {
    let offers: StdResult<Vec<_>> = OFFERS_BY_NFT
        .prefix(nft_token_id)
        .range(deps.storage, None, None, Order::Descending)
        .take(1)
        .map(|item| {
            let (offer_id, _) = item?;
            let offer = OFFERS.load(deps.storage, offer_id)?;
            Ok((offer_id, offer.token_amount))
        })
        .collect();

    let offers = offers?;
    Ok(offers.into_iter().next())
}

pub fn get_cheapest_listing(
    deps: Deps,
) -> StdResult<Option<(u64, Uint128, String)>> {
    let cheapest = LISTINGS
        .range(deps.storage, None, None, Order::Ascending)
        .filter(|item| {
            if let Ok((_, listing)) = item {
                listing.status == ListingStatus::Active
            } else { false }
        })
        .min_by_key(|item| {
            if let Ok((_, listing)) = item { listing.price.u128() }
            else { u128::MAX }
        });

    match cheapest {
        Some(Ok((_, listing))) => {
            Ok(Some((listing.listing_id, listing.price, listing.nft_token_id)))
        }
        _ => Ok(None),
    }
}

3.5 部分成交支持

NFT 是不可分割的,但从报价簿角度,"部分成交"意味着:

  1. 报价的部分接受: 如果一个报价针对某个 NFT,但卖家想按比例成交,这通常不适用于 NFT(NFT 是原子的)。
  2. 挂单的多次购买: 不能分割一个 NFT。
  3. 聚合流动性: 多个买家对同一个 NFT 出价,卖家选择最优报价。
pub fn get_offers_for_nft_sorted(
    deps: Deps,
    nft_token_id: String,
) -> StdResult<Vec<OfferResponse>> {
    let mut offers: Vec<OfferResponse> = list_offers_for_nft(
        deps, nft_token_id, None, Some(MAX_LIMIT),
    )?.offers;

    offers.sort_by(|a, b| b.token_amount.cmp(&a.token_amount));
    Ok(offers)
}

3.6 过期清理

pub fn prune_expired_offers(
    deps: DepsMut,
    env: Env,
    limit: Option<u32>,
) -> Result<Response, ContractError> {
    let limit = limit.unwrap_or(10).min(50);
    let mut pruned = 0u64;

    let expired_ids: Vec<u64> = OFFERS
        .range(deps.storage, None, None, Order::Ascending)
        .take(limit as usize)
        .filter(|item| {
            if let Ok((_, offer)) = item {
                offer.status == OfferStatus::Active
                    && offer.expires_at.map_or(false, |exp| env.block.height > exp)
            } else { false }
        })
        .map(|item| { let (id, _) = item.unwrap(); id })
        .collect();

    for offer_id in &expired_ids {
        let mut offer = OFFERS.load(deps.storage, *offer_id)?;
        offer.status = OfferStatus::Cancelled;
        OFFERS.save(deps.storage, *offer_id, &offer)?;

        let config = CONFIG.load(deps.storage)?;
        let refund_msg = CosmosMsg::Wasm(WasmMsg::Execute {
            contract_addr: config.token_contract.to_string(),
            msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
                recipient: offer.buyer.to_string(),
                amount: offer.token_amount,
            })?,
            funds: vec![],
        });
        pruned += 1;
    }

    Ok(Response::new()
        .add_attribute("action", "prune_expired")
        .add_attribute("pruned_count", pruned.to_string()))
}

3.7 Python 报价簿分析脚本

#!/usr/bin/env python3
"""报价簿分析工具 — 用于分析合约中的报价簿数据"""

from dataclasses import dataclass
from typing import Optional, List
from decimal import Decimal

@dataclass
class Offer:
    offer_id: int
    buyer: str
    nft_token_id: str
    token_amount: int
    created_at: int
    expires_at: Optional[int]
    status: str

@dataclass
class Listing:
    listing_id: int
    seller: str
    nft_token_id: str
    price: int
    created_at: int
    status: str

class OrderBookAnalyzer:
    def __init__(self, decimals: int = 18):
        self.decimals = decimals
        self.offers: List[Offer] = []
        self.listings: List[Listing] = []

    def add_offer(self, offer: Offer):
        self.offers.append(offer)

    def add_listing(self, listing: Listing):
        self.listings.append(listing)

    def get_active_offers(self) -> List[Offer]:
        return [o for o in self.offers if o.status == "active"]

    def get_active_listings(self) -> List[Listing]:
        return [l for l in self.listings if l.status == "active"]

    def get_floor_price(self) -> Optional[Decimal]:
        active = self.get_active_listings()
        if not active:
            return None
        min_price = min(l.price for l in active)
        return Decimal(min_price) / Decimal(10 ** self.decimals)

    def get_highest_bid(self, token_id: Optional[str] = None) -> Optional[Decimal]:
        active = self.get_active_offers()
        if token_id:
            active = [o for o in active if o.nft_token_id == token_id]
        if not active:
            return None
        max_bid = max(o.token_amount for o in active)
        return Decimal(max_bid) / Decimal(10 ** self.decimals)

    def get_spread(self, token_id: Optional[str] = None) -> Optional[Decimal]:
        if token_id:
            floor = None
            for l in self.get_active_listings():
                if l.nft_token_id == token_id:
                    floor = Decimal(l.price) / Decimal(10 ** self.decimals)
                    break
            highest_bid = self.get_highest_bid(token_id)
        else:
            floor = self.get_floor_price()
            highest_bid = self.get_highest_bid()
        if floor is None or highest_bid is None:
            return None
        return floor - highest_bid

    def analyze_collection(self) -> dict:
        active_offers = self.get_active_offers()
        active_listings = self.get_active_listings()
        total_bid_volume = sum(o.token_amount for o in active_offers)
        total_listing_volume = sum(l.price for l in active_listings)
        unique_bidders = len(set(o.buyer for o in active_offers))
        unique_sellers = len(set(l.seller for l in active_listings))
        return {
            "active_offers": len(active_offers),
            "active_listings": len(active_listings),
            "total_bid_volume": total_bid_volume / 10 ** self.decimals,
            "total_listing_volume": total_listing_volume / 10 ** self.decimals,
            "unique_bidders": unique_bidders,
            "unique_sellers": unique_sellers,
            "floor_price": self.get_floor_price(),
            "highest_bid": self.get_highest_bid(),
            "spread": self.get_spread(),
        }

    def print_summary(self):
        summary = self.analyze_collection()
        print("=" * 50)
        print("报价簿分析报告")
        print("=" * 50)
        print(f"活跃报价数:     {summary['active_offers']}")
        print(f"活跃挂单数:     {summary['active_listings']}")
        print(f"总出价金额:     {summary['total_bid_volume']:.4f} MSG")
        print(f"总挂单金额:     {summary['total_listing_volume']:.4f} MSG")
        print(f"独立买家数:     {summary['unique_bidders']}")
        print(f"独立卖家数:     {summary['unique_sellers']}")
        print(f"地板价:        {summary['floor_price']}")
        print(f"最高出价:      {summary['highest_bid']}")
        print(f"买卖价差:      {summary['spread']}")
        print("=" * 50)

if __name__ == "__main__":
    analyzer = OrderBookAnalyzer(decimals=18)
    analyzer.add_offer(Offer(1, "msg1abc", "nft001", 500_000_000_000_000_000, 1000, None, "active"))
    analyzer.add_offer(Offer(2, "msg1def", "nft001", 750_000_000_000_000_000, 1001, 2000, "active"))
    analyzer.add_listing(Listing(1, "msg1xyz", "nft001", 1_000_000_000_000_000_000, 1000, "active"))
    analyzer.add_listing(Listing(2, "msg1xyz", "nft002", 2_000_000_000_000_000_000, 1002, "active"))
    analyzer.print_summary()

3.8 TypeScript 离线报价簿匹配

// orderbook.ts — 客户端报价簿匹配引擎

interface Offer {
  offerId: number;
  buyer: string;
  nftTokenId: string;
  tokenAmount: string;
  createdAt: number;
  expiresAt: number | null;
  status: "active" | "accepted" | "cancelled";
}

interface Listing {
  listingId: number;
  seller: string;
  nftTokenId: string;
  price: string;
  createdAt: number;
  status: "active" | "sold" | "withdrawn";
}

interface MatchResult {
  type: "offer_match" | "listing_match" | "no_match";
  listingId?: number;
  offerId?: number;
  price: string;
  seller: string;
  buyer: string;
  nftTokenId: string;
}

export class ClientOrderBook {
  private offers: Offer[] = [];
  private listings: Listing[] = [];

  loadOffers(offers: Offer[]): void {
    this.offers = offers.filter(o => o.status === "active");
  }

  loadListings(listings: Listing[]): void {
    this.listings = listings.filter(l => l.status === "active");
  }

  findMatchingListing(offer: Offer): Listing | null {
    const candidates = this.listings
      .filter(l => l.nftTokenId === offer.nftTokenId)
      .filter(l => BigInt(l.price) <= BigInt(offer.tokenAmount))
      .sort((a, b) => Number(BigInt(a.price) - BigInt(b.price)));
    return candidates[0] || null;
  }

  findMatchingOffer(listing: Listing): Offer | null {
    const candidates = this.offers
      .filter(o => o.nftTokenId === listing.nftTokenId)
      .filter(o => BigInt(o.tokenAmount) >= BigInt(listing.price))
      .sort((a, b) => Number(BigInt(b.tokenAmount) - BigInt(a.tokenAmount)));
    return candidates[0] || null;
  }

  autoMatchAll(): MatchResult[] {
    const results: MatchResult[] = [];
    const matchedNfts = new Set<string>();

    for (const listing of this.listings) {
      if (matchedNfts.has(listing.nftTokenId)) continue;
      const offer = this.findMatchingOffer(listing);
      if (offer) {
        matchedNfts.add(listing.nftTokenId);
        results.push({
          type: "listing_match",
          listingId: listing.listingId,
          offerId: offer.offerId,
          price: listing.price,
          seller: listing.seller,
          buyer: offer.buyer,
          nftTokenId: listing.nftTokenId,
        });
      }
    }
    return results;
  }

  getLiquidityMetrics() {
    if (this.listings.length === 0) {
      return {
        totalOffers: this.offers.length,
        totalListings: 0,
        matchableListings: 0,
        matchableOffers: 0,
        coverage: 0,
      };
    }
    const matchableListings = this.listings.filter(
      l => this.findMatchingOffer(l) !== null
    ).length;
    const matchableOffers = this.offers.filter(
      o => this.findMatchingListing(o) !== null
    ).length;
    return {
      totalOffers: this.offers.length,
      totalListings: this.listings.length,
      matchableListings,
      matchableOffers,
      coverage: matchableListings / this.listings.length,
    };
  }
}

4. 交易市场前端

4.1 项目初始化

npx create-react-app nft-marketplace --template typescript
cd nft-marketplace
npm install @cosmjs/cosmwasm-stargate @cosmjs/stargate @cosmjs/proto-signing @cosmjs/encoding
npm install @cosmjs/amino @cosmjs/tendermint-rpc buffer
npm install @mui/material @emotion/react @emotion/styled
npm install react-router-dom @tanstack/react-query
npm install -D @types/node

4.2 CosmJS 集成工具

// src/lib/cosmjs.ts — MSG Chain 客户端配置

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

export const MSG_CHAIN_CONFIG = {
  chainId: "msg-chain-1",
  rpcUrl: "https://rpc.msg-chain-1.com",  // 替换为实际 RPC
  restUrl: "https://rest.msg-chain-1.com",
  bech32Prefix: "msg",
  denom: "umsg",
  gasPrice: GasPrice.fromString("1000000000attoMSG"),
};

export class MsgChainClient {
  private client: CosmWasmClient | null = null;
  private signingClient: SigningCosmWasmClient | null = null;

  async connect(): Promise<void> {
    this.client = await CosmWasmClient.connect(MSG_CHAIN_CONFIG.rpcUrl);
  }

  async connectSigning(wallet: DirectSecp256k1HdWallet): Promise<void> {
    this.signingClient = await SigningCosmWasmClient.connectWithSigner(
      MSG_CHAIN_CONFIG.rpcUrl,
      wallet,
      { gasPrice: MSG_CHAIN_CONFIG.gasPrice }
    );
  }

  async queryContract<T>(contractAddr: string, query: Record<string, unknown>): Promise<T> {
    if (!this.client) await this.connect();
    return this.client!.queryContractSmart(contractAddr, query);
  }

  async getConfig(contractAddr: string): Promise<any> {
    return this.queryContract(contractAddr, { get_config: {} });
  }

  async getOffer(contractAddr: string, offerId: number): Promise<any> {
    return this.queryContract(contractAddr, { get_offer: { offer_id: offerId } });
  }

  async listOffers(contractAddr: string, startAfter?: number, limit?: number): Promise<any> {
    return this.queryContract(contractAddr, {
      list_offers: { start_after: startAfter, limit: limit || 30 },
    });
  }

  async listListings(contractAddr: string, startAfter?: number, limit?: number): Promise<any> {
    return this.queryContract(contractAddr, {
      list_listings: { start_after: startAfter, limit: limit || 30 },
    });
  }

  async getListingsBySeller(contractAddr: string, seller: string): Promise<any> {
    return this.queryContract(contractAddr, { list_listings_by_seller: { seller } });
  }

  async getOffersForNft(contractAddr: string, tokenId: string): Promise<any> {
    return this.queryContract(contractAddr, { list_offers_for_nft: { nft_token_id: tokenId } });
  }

  async getStats(contractAddr: string): Promise<any> {
    return this.queryContract(contractAddr, { get_stats: {} });
  }

  async makeOffer(
    contractAddr: string, tokenAddr: string, tokenAmount: string,
    nftTokenId: string, expiresAt?: number
  ): Promise<ExecuteResult> {
    if (!this.signingClient) throw new Error("Signing client not connected");
    const sender = await this.signingClient.getSenderAddress();

    await this.signingClient.execute(sender, tokenAddr, {
      increase_allowance: { spender: contractAddr, amount: tokenAmount, expires: { never: {} } },
    }, "auto", "");

    return this.signingClient.execute(sender, contractAddr, {
      make_offer: { token_amount: tokenAmount, nft_token_id: nftTokenId, expires_at: expiresAt },
    }, "auto", "");
  }

  async acceptOffer(contractAddr: string, offerId: number): Promise<ExecuteResult> {
    if (!this.signingClient) throw new Error("Signing client not connected");
    return this.signingClient.execute(
      await this.signingClient.getSenderAddress(), contractAddr,
      { accept_offer: { offer_id: offerId } }, "auto", ""
    );
  }

  async cancelOffer(contractAddr: string, offerId: number): Promise<ExecuteResult> {
    if (!this.signingClient) throw new Error("Signing client not connected");
    return this.signingClient.execute(
      await this.signingClient.getSenderAddress(), contractAddr,
      { cancel_offer: { offer_id: offerId } }, "auto", ""
    );
  }

  async listNft(contractAddr: string, nftContractAddr: string, tokenId: string, price: string): Promise<ExecuteResult> {
    if (!this.signingClient) throw new Error("Signing client not connected");
    const sender = await this.signingClient.getSenderAddress();

    await this.signingClient.execute(sender, nftContractAddr, {
      approve: { spender: contractAddr, token_id: tokenId, expires: { never: {} } },
    }, "auto", "");

    return this.signingClient.execute(sender, contractAddr, {
      list_nft: { token_id: tokenId, price },
    }, "auto", "");
  }

  async buyNft(contractAddr: string, tokenAddr: string, listingId: number, price: string): Promise<ExecuteResult> {
    if (!this.signingClient) throw new Error("Signing client not connected");
    const sender = await this.signingClient.getSenderAddress();

    await this.signingClient.execute(sender, tokenAddr, {
      increase_allowance: { spender: contractAddr, amount: price, expires: { never: {} } },
    }, "auto", "");

    return this.signingClient.execute(sender, contractAddr, {
      buy_nft: { listing_id: listingId },
    }, "auto", "");
  }

  async withdrawNft(contractAddr: string, listingId: number): Promise<ExecuteResult> {
    if (!this.signingClient) throw new Error("Signing client not connected");
    return this.signingClient.execute(
      await this.signingClient.getSenderAddress(), contractAddr,
      { withdraw_nft: { listing_id: listingId } }, "auto", ""
    );
  }
}

export const msgChainClient = new MsgChainClient();

4.3 类型定义

// src/types/marketplace.ts

export interface MarketConfig {
  nftContract: string;
  tokenContract: string;
  feePercent: string;
  feeAddress: string;
  owner: string;
  offerCount: number;
  listingCount: number;
}

export interface Offer {
  offerId: number;
  buyer: string;
  nftTokenId: string;
  tokenAmount: string;
  createdAt: number;
  expiresAt: number | null;
  status: "active" | "accepted" | "cancelled";
}

export interface Listing {
  listingId: number;
  seller: string;
  nftTokenId: string;
  price: string;
  createdAt: number;
  status: "active" | "sold" | "withdrawn";
}

export interface MarketStats {
  activeOffers: number;
  activeListings: number;
  totalVolume: string;
  totalFees: string;
}

export interface NftMetadata {
  tokenId: string;
  name: string;
  description: string;
  image: string;
  attributes?: Array<{ trait_type: string; value: string }>;
}

export interface WalletInfo {
  address: string;
  balance: string;
}

4.4 React Hooks

// src/hooks/useMarketplace.ts

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { msgChainClient } from "../lib/cosmjs";
import type { MarketConfig, Offer, Listing, MarketStats } from "../types/marketplace";

const MARKETPLACE_CONTRACT = "msg1...your_contract_address";

export function useMarketConfig() {
  return useQuery<MarketConfig>({
    queryKey: ["marketplace", "config"],
    queryFn: () => msgChainClient.getConfig(MARKETPLACE_CONTRACT),
  });
}

export function useOffers(startAfter?: number, limit?: number) {
  return useQuery<{ offers: Offer[] }>({
    queryKey: ["marketplace", "offers", startAfter, limit],
    queryFn: () => msgChainClient.listOffers(MARKETPLACE_CONTRACT, startAfter, limit),
    refetchInterval: 15000,
  });
}

export function useListings(startAfter?: number, limit?: number) {
  return useQuery<{ listings: Listing[] }>({
    queryKey: ["marketplace", "listings", startAfter, limit],
    queryFn: () => msgChainClient.listListings(MARKETPLACE_CONTRACT, startAfter, limit),
    refetchInterval: 15000,
  });
}

export function useSellerListings(seller: string) {
  return useQuery<{ listings: Listing[] }>({
    queryKey: ["marketplace", "my-listings", seller],
    queryFn: () => msgChainClient.getListingsBySeller(MARKETPLACE_CONTRACT, seller),
    enabled: !!seller,
  });
}

export function useOffersForNft(nftTokenId: string) {
  return useQuery<{ offers: Offer[] }>({
    queryKey: ["marketplace", "nft-offers", nftTokenId],
    queryFn: () => msgChainClient.getOffersForNft(MARKETPLACE_CONTRACT, nftTokenId),
    enabled: !!nftTokenId,
  });
}

export function useMarketStats() {
  return useQuery<MarketStats>({
    queryKey: ["marketplace", "stats"],
    queryFn: () => msgChainClient.getStats(MARKETPLACE_CONTRACT),
    refetchInterval: 30000,
  });
}

export function useMakeOffer() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ tokenAmount, nftTokenId, expiresAt }: {
      tokenAmount: string; nftTokenId: string; expiresAt?: number;
    }) => {
      return msgChainClient.makeOffer(MARKETPLACE_CONTRACT, "", tokenAmount, nftTokenId, expiresAt);
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["marketplace"] }),
  });
}

export function useAcceptOffer() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ offerId }: { offerId: number }) =>
      msgChainClient.acceptOffer(MARKETPLACE_CONTRACT, offerId),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["marketplace"] }),
  });
}

export function useCancelOffer() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ offerId }: { offerId: number }) =>
      msgChainClient.cancelOffer(MARKETPLACE_CONTRACT, offerId),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["marketplace"] }),
  });
}

export function useListNft() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ nftContract, tokenId, price }: {
      nftContract: string; tokenId: string; price: string;
    }) => msgChainClient.listNft(MARKETPLACE_CONTRACT, nftContract, tokenId, price),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["marketplace"] }),
  });
}

export function useBuyNft() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ tokenContract, listingId, price }: {
      tokenContract: string; listingId: number; price: string;
    }) => msgChainClient.buyNft(MARKETPLACE_CONTRACT, tokenContract, listingId, price),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["marketplace"] }),
  });
}

4.5 钱包连接组件

// src/components/WalletConnect.tsx

import React, { useState, useCallback } from "react";
import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Typography, Box, CircularProgress } from "@mui/material";
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { msgChainClient, MSG_CHAIN_CONFIG } from "../lib/cosmjs";

interface WalletConnectProps {
  onWalletConnected: (address: string) => void;
  onWalletDisconnected: () => void;
  walletAddress: string | null;
}

export const WalletConnect: React.FC<WalletConnectProps> = ({
  onWalletConnected, onWalletDisconnected, walletAddress,
}) => {
  const [open, setOpen] = useState(false);
  const [connecting, setConnecting] = useState(false);
  const [mnemonicInput, setMnemonicInput] = useState("");

  const connectWithMnemonic = useCallback(async () => {
    setConnecting(true);
    try {
      const wallet = await DirectSecp256k1HdWallet.fromMnemonic(mnemonicInput, {
        prefix: MSG_CHAIN_CONFIG.bech32Prefix,
      });
      await msgChainClient.connectSigning(wallet);
      const accounts = await wallet.getAccounts();
      onWalletConnected(accounts[0].address);
      setOpen(false);
    } catch (err) {
      console.error(err);
      alert("连接失败,请检查助记词");
    } finally {
      setConnecting(false);
    }
  }, [mnemonicInput, onWalletConnected]);

  const connectWithKeplr = useCallback(async () => {
    if (!(window as any).keplr) { alert("请先安装 Keplr"); return; }
    setConnecting(true);
    try {
      await (window as any).keplr.enable(MSG_CHAIN_CONFIG.chainId);
      const signer = (window as any).keplr.getOfflineSigner(MSG_CHAIN_CONFIG.chainId);
      await msgChainClient.connectSigning(signer);
      const accounts = await signer.getAccounts();
      onWalletConnected(accounts[0].address);
      setOpen(false);
    } catch (err) {
      console.error(err);
    } finally {
      setConnecting(false);
    }
  }, [onWalletConnected]);

  const disconnect = () => onWalletDisconnected();

  return (
    <>
      {walletAddress ? (
        <Box display="flex" alignItems="center" gap={2}>
          <Typography variant="body2" color="text.secondary">
            {walletAddress.slice(0, 6)}...{walletAddress.slice(-4)}
          </Typography>
          <Button variant="outlined" size="small" onClick={disconnect}>断开</Button>
        </Box>
      ) : (
        <Button variant="contained" onClick={() => setOpen(true)}>连接钱包</Button>
      )}
      <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
        <DialogTitle>连接钱包</DialogTitle>
        <DialogContent>
          <Box display="flex" flexDirection="column" gap={2} mt={1}>
            <Button variant="contained" onClick={connectWithKeplr} disabled={connecting} fullWidth>
              {connecting ? <CircularProgress size={24} /> : "连接 Keplr"}
            </Button>
            <Typography variant="body2" color="text.secondary" textAlign="center">--- 或使用助记词 ---</Typography>
            <textarea
              placeholder="输入助记词 (12或24个单词)"
              value={mnemonicInput}
              onChange={(e) => setMnemonicInput(e.target.value)}
              rows={3}
              style={{ width: "100%", padding: "8px", borderRadius: "4px", border: "1px solid #ccc", fontFamily: "monospace" }}
            />
            <Button variant="outlined" onClick={connectWithMnemonic} disabled={connecting || !mnemonicInput.trim()} fullWidth>
              导入助记词
            </Button>
          </Box>
        </DialogContent>
        <DialogActions>
          <Button onClick={() => setOpen(false)}>取消</Button>
        </DialogActions>
      </Dialog>
    </>
  );
};

4.6 NFT 网格组件

// src/components/NftGrid.tsx

import React, { useState } from "react";
import { Grid, Card, CardMedia, CardContent, CardActions, Typography, Button, Chip, Skeleton, Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, CircularProgress } from "@mui/material";
import { useListings, useBuyNft, useMakeOffer, useMarketConfig } from "../hooks/useMarketplace";
import type { Listing, NftMetadata } from "../types/marketplace";

const formatToken = (amount: string): string => {
  const bi = BigInt(amount);
  const div = BigInt(10 ** 18);
  const intPart = bi / div;
  const fracPart = bi % div;
  if (fracPart === BigInt(0)) return intPart.toString();
  let frac = fracPart.toString().padStart(18, "0").replace(/0+$/, "");
  return `${intPart}.${frac}`;
};

interface NftCardProps {
  listing: Listing;
  nftMetadata?: NftMetadata;
  walletAddress: string | null;
  onOffer?: (listing: Listing) => void;
  onBuy?: (listing: Listing) => void;
}

const NftCard: React.FC<NftCardProps> = ({ listing, nftMetadata, walletAddress, onOffer, onBuy }) => {
  const isOwnListing = walletAddress === listing.seller;
  return (
    <Card sx={{ maxWidth: 345 }}>
      {nftMetadata?.image ? (
        <CardMedia component="img" height="200" image={nftMetadata.image}
          alt={nftMetadata?.name || `NFT #${listing.nftTokenId}`} sx={{ objectFit: "cover" }} />
      ) : (
        <Skeleton variant="rectangular" height={200} />
      )}
      <CardContent>
        <Typography gutterBottom variant="h6" noWrap>{nftMetadata?.name || `#${listing.nftTokenId}`}</Typography>
        <Typography variant="body2" color="text.secondary" gutterBottom>
          价格: {formatToken(listing.price)} MSG
        </Typography>
        <Box display="flex" gap={1} flexWrap="wrap">
          {nftMetadata?.attributes?.slice(0, 3).map((attr, i) => (
            <Chip key={i} label={`${attr.trait_type}: ${attr.value}`} size="small" variant="outlined" />
          ))}
        </Box>
        <Typography variant="caption" color="text.secondary" display="block" mt={1}>
          卖家: {listing.seller.slice(0, 8)}...
        </Typography>
      </CardContent>
      <CardActions>
        {!isOwnListing && walletAddress && (
          <>
            <Button size="small" variant="contained" onClick={() => onBuy?.(listing)} fullWidth>立即购买</Button>
            <Button size="small" variant="outlined" onClick={() => onOffer?.(listing)}>出价</Button>
          </>
        )}
        {isOwnListing && <Typography variant="caption" color="text.secondary" sx={{ px: 2 }}>您的挂单</Typography>}
      </CardActions>
    </Card>
  );
};
export const NftGrid: React.FC<{ walletAddress: string | null; nftMetadataMap?: Record<string, NftMetadata> }> = ({
  walletAddress, nftMetadataMap = {},
}) => {
  const { data: listingsData, isLoading, error } = useListings();
  const config = useMarketConfig();
  const buyNft = useBuyNft();
  const makeOffer = useMakeOffer();
  const [offerDialogOpen, setOfferDialogOpen] = useState(false);
  const [selectedListing, setSelectedListing] = useState<Listing | null>(null);
  const [offerAmount, setOfferAmount] = useState("");
  const [buyingId, setBuyingId] = useState<number | null>(null);

  const handleBuy = async (listing: Listing) => {
    if (!config.data) return;
    setBuyingId(listing.listingId);
    try {
      await buyNft.mutateAsync({ tokenContract: config.data.tokenContract, listingId: listing.listingId, price: listing.price });
      alert("购买成功!");
    } catch (err: any) { alert(`购买失败: ${err.message}`); }
    finally { setBuyingId(null); }
  };

  const handleOpenOffer = (listing: Listing) => { setSelectedListing(listing); setOfferAmount(""); setOfferDialogOpen(true); };

  const handleMakeOffer = async () => {
    if (!selectedListing || !offerAmount) return;
    try {
      const amt = BigInt(Math.floor(parseFloat(offerAmount) * 10 ** 18)).toString();
      await makeOffer.mutateAsync({ tokenAmount: amt, nftTokenId: selectedListing.nftTokenId });
      alert("出价成功!"); setOfferDialogOpen(false);
    } catch (err: any) { alert(`出价失败: ${err.message}`); }
  };

  if (isLoading) return <Skeleton variant="rectangular" height={400} />;
  if (error) return <Typography color="error">加载失败: {(error as Error).message}</Typography>;

  return (
    <>
      <Grid container spacing={3}>
        {listingsData?.listings.map((listing) => (
          <Grid item xs={12} sm={6} md={4} lg={3} key={listing.listingId}>
            <NftCard listing={listing} nftMetadata={nftMetadataMap[listing.nftTokenId]}
              walletAddress={walletAddress} onBuy={buyingId === listing.listingId ? undefined : handleBuy}
              onOffer={handleOpenOffer} />
          </Grid>
        ))}
        {listingsData?.listings.length === 0 && (
          <Grid item xs={12}><Typography textAlign="center" color="text.secondary" py={4}>暂无活跃挂单</Typography></Grid>
        )}
      </Grid>
      <Dialog open={offerDialogOpen} onClose={() => setOfferDialogOpen(false)} maxWidth="xs" fullWidth>
        <DialogTitle>出价购买 #{selectedListing?.nftTokenId}</DialogTitle>
        <DialogContent>
          <Typography variant="body2" color="text.secondary" gutterBottom>
            当前售价: {selectedListing && formatToken(selectedListing.price)} MSG
          </Typography>
          <TextField autoFocus margin="dense" label="您的出价 (MSG)" type="number" fullWidth
            value={offerAmount} onChange={(e) => setOfferAmount(e.target.value)}
            placeholder={`最低 ${selectedListing ? formatToken(selectedListing.price) : "0"} MSG`} />
        </DialogContent>
        <DialogActions>
          <Button onClick={() => setOfferDialogOpen(false)}>取消</Button>
          <Button onClick={handleMakeOffer} variant="contained"
            disabled={!offerAmount || parseFloat(offerAmount) <= 0 || makeOffer.isPending}>
            {makeOffer.isPending ? <CircularProgress size={24} /> : "确认出价"}
          </Button>
        </DialogActions>
      </Dialog>
    </>
  );
};

4.7 我的挂单面板

// src/components/MyListings.tsx

import React from "react";
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Chip, IconButton, Tooltip, CircularProgress } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import { useSellerListings, useWithdrawNft } from "../hooks/useMarketplace";

export const MyListings: React.FC<{ walletAddress: string }> = ({ walletAddress }) => {
  const { data, isLoading, error } = useSellerListings(walletAddress);
  const withdrawNft = useWithdrawNft();

  const handleWithdraw = async (listingId: number) => {
    if (!confirm("确定要撤回此挂单吗?")) return;
    try { await withdrawNft.mutateAsync({ listingId }); alert("挂单已撤回"); }
    catch (err: any) { alert(`撤回失败: ${err.message}`); }
  };

  if (isLoading) return <CircularProgress />;
  if (error) return <Typography color="error">加载失败: {(error as Error).message}</Typography>;

  const activeListings = data?.listings.filter(l => l.status === "active") || [];
  const historyListings = data?.listings.filter(l => l.status !== "active") || [];

  const formatToken = (amount: string) => {
    const bi = BigInt(amount); const div = BigInt(10 ** 18);
    const intPart = bi / div; const fracPart = bi % div;
    if (fracPart === BigInt(0)) return intPart.toString();
    return `${intPart}.${fracPart.toString().padStart(18, "0").replace(/0+$/, "")}`;
  };

  return (
    <>
      <Typography variant="h5" gutterBottom>我的挂单 ({activeListings.length})</Typography>
      {activeListings.length === 0 ? <Typography color="text.secondary">暂无活跃挂单</Typography> : (
        <TableContainer component={Paper} sx={{ mb: 4 }}>
          <Table size="small">
            <TableHead><TableRow>
              <TableCell>NFT Token ID</TableCell><TableCell align="right">价格 (MSG)</TableCell>
              <TableCell align="right">创建高度</TableCell><TableCell align="right">状态</TableCell><TableCell align="right">操作</TableCell>
            </TableRow></TableHead>
            <TableBody>{activeListings.map(listing => (
              <TableRow key={listing.listingId}>
                <TableCell>{listing.nftTokenId}</TableCell>
                <TableCell align="right">{formatToken(listing.price)}</TableCell>
                <TableCell align="right">{listing.createdAt}</TableCell>
                <TableCell align="right"><Chip label="活跃" color="success" size="small" /></TableCell>
                <TableCell align="right">
                  <Tooltip title="撤回挂单">
                    <IconButton onClick={() => handleWithdraw(listing.listingId)} disabled={withdrawNft.isPending} size="small" color="error">
                      <DeleteIcon />
                    </IconButton>
                  </Tooltip>
                </TableCell>
              </TableRow>
            ))}</TableBody>
          </Table>
        </TableContainer>
      )}
      {historyListings.length > 0 && (
        <>
          <Typography variant="h6" gutterBottom>历史记录</Typography>
          <TableContainer component={Paper}>
            <Table size="small">
              <TableHead><TableRow>
                <TableCell>NFT Token ID</TableCell><TableCell align="right">价格 (MSG)</TableCell><TableCell align="right">状态</TableCell>
              </TableRow></TableHead>
              <TableBody>{historyListings.map(listing => (
                <TableRow key={listing.listingId}>
                  <TableCell>{listing.nftTokenId}</TableCell>
                  <TableCell align="right">{formatToken(listing.price)}</TableCell>
                  <TableCell align="right">
                    <Chip label={listing.status === "sold" ? "已售出" : "已撤回"}
                      color={listing.status === "sold" ? "default" : "warning"} size="small" />
                  </TableCell>
                </TableRow>
              ))}</TableBody>
            </Table>
          </TableContainer>
        </>
      )}
    </>
  );
};

4.8 挂单弹窗

// src/components/ListNftDialog.tsx

import React, { useState } from "react";
import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, Typography, CircularProgress, Alert } from "@mui/material";
import { useListNft } from "../hooks/useMarketplace";

interface Props { open: boolean; onClose: () => void; nftContract: string; tokenId: string; tokenName?: string; }

export const ListNftDialog: React.FC<Props> = ({ open, onClose, nftContract, tokenId, tokenName }) => {
  const [price, setPrice] = useState("");
  const listNft = useListNft();

  const handleList = async () => {
    if (!price || parseFloat(price) <= 0) { alert("请输入有效价格"); return; }
    const priceInBase = BigInt(Math.floor(parseFloat(price) * 10 ** 18)).toString();
    try {
      await listNft.mutateAsync({ nftContract, tokenId, price: priceInBase });
      alert("挂单成功!"); onClose(); setPrice("");
    } catch (err: any) { alert(`挂单失败: ${err.message}`); }
  };

  return (
    <Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
      <DialogTitle>上架 NFT</DialogTitle>
      <DialogContent>
        <Typography variant="body2" color="text.secondary" gutterBottom>NFT: {tokenName || `#${tokenId}`}</Typography>
        <Typography variant="body2" color="text.secondary" gutterBottom>合约: {nftContract.slice(0, 10)}...{nftContract.slice(-6)}</Typography>
        <TextField autoFocus margin="dense" label="售价 (MSG)" type="number" fullWidth
          value={price} onChange={(e) => setPrice(e.target.value)} placeholder="0.00"
          inputProps={{ min: 0, step: 0.01 }} sx={{ mt: 2 }} />
        {listNft.isError && <Alert severity="error" sx={{ mt: 2 }}>{(listNft.error as Error).message}</Alert>}
        <Typography variant="caption" color="text.secondary" display="block" mt={2}>
          上架后 NFT 将转入合约托管。成交后将扣除 2% 手续费。
        </Typography>
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose}>取消</Button>
        <Button onClick={handleList} variant="contained" disabled={listNft.isPending || !price}>
          {listNft.isPending ? <CircularProgress size={24} /> : "确认上架"}
        </Button>
      </DialogActions>
    </Dialog>
  );
};

4.9 主页面

// src/App.tsx

import React, { useState } from "react";
import { AppBar, Toolbar, Typography, Container, Box, Tabs, Tab, CssBaseline, ThemeProvider, createTheme } from "@mui/material";
import StorefrontIcon from "@mui/icons-material/Storefront";
import DashboardIcon from "@mui/icons-material/Dashboard";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WalletConnect } from "./components/WalletConnect";
import { NftGrid } from "./components/NftGrid";
import { MyListings } from "./components/MyListings";
import { useMarketStats } from "./hooks/useMarketplace";

const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 2, staleTime: 10000 } } });
const darkTheme = createTheme({ palette: { mode: "dark", primary: { main: "#90caf9" } } });

const StatsBar: React.FC = () => {
  const { data: stats } = useMarketStats();
  return (
    <Box display="flex" justifyContent="space-around" py={2} mb={3} sx={{ bgcolor: "background.paper", borderRadius: 1, px: 2 }}>
      <Box textAlign="center">
        <Typography variant="h6">{stats?.activeListings || 0}</Typography>
        <Typography variant="caption" color="text.secondary">在售</Typography>
      </Box>
      <Box textAlign="center">
        <Typography variant="h6">{stats?.activeOffers || 0}</Typography>
        <Typography variant="caption" color="text.secondary">活跃报价</Typography>
      </Box>
      <Box textAlign="center">
        <Typography variant="h6">{stats?.totalVolume ? (BigInt(stats.totalVolume) / BigInt(10 ** 18)).toString() : "0"}</Typography>
        <Typography variant="caption" color="text.secondary">总交易量 (MSG)</Typography>
      </Box>
    </Box>
  );
};

const AppInner: React.FC = () => {
  const [tabIndex, setTabIndex] = useState(0);
  const [walletAddress, setWalletAddress] = useState<string | null>(null);
  return (
    <ThemeProvider theme={darkTheme}>
      <CssBaseline />
      <AppBar position="static">
        <Toolbar>
          <StorefrontIcon sx={{ mr: 1 }} />
          <Typography variant="h6" sx={{ flexGrow: 1 }}>MSG NFT 市场</Typography>
          <WalletConnect onWalletConnected={setWalletAddress} onWalletDisconnected={() => setWalletAddress(null)} walletAddress={walletAddress} />
        </Toolbar>
      </AppBar>
      <Container maxWidth="xl" sx={{ mt: 3 }}>
        <StatsBar />
        <Tabs value={tabIndex} onChange={(_, idx) => setTabIndex(idx)} sx={{ mb: 3 }}>
          <Tab label="市场" icon={<StorefrontIcon />} iconPosition="start" />
          <Tab label="我的挂单" icon={<DashboardIcon />} iconPosition="start" disabled={!walletAddress} />
        </Tabs>
        {tabIndex === 0 && <NftGrid walletAddress={walletAddress} />}
        {tabIndex === 1 && walletAddress && <MyListings walletAddress={walletAddress} />}
      </Container>
    </ThemeProvider>
  );
};

const App: React.FC = () => (
  <QueryClientProvider client={queryClient}><AppInner /></QueryClientProvider>
);
export default App;

4.10 前端部署配置

// src/config.ts

export const MARKETPLACE_CONFIG = {
  contractAddress: "msg1abcdef1234567890abcdef1234567890abcdef",
  nftContract: "msg1nftcontract1234567890abcdef1234567890abcde",
  tokenContract: "msg1tokencontract1234567890abcdef1234567890abcd",
  rpcEndpoint: "https://rpc.msg-chain-1.com",
  feePercent: 0.02,
  decimals: 18,
  refetchInterval: 15000,
  pageSize: 30,
};

5. 高级功能

5.1 批量挂单

扩展合约以支持批量操作:

// src/msg.rs 追加

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
    // ... 原有消息 ...

    /// 批量挂单
    BatchListNft {
        listings: Vec<BatchNftItem>,
    },
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct BatchNftItem {
    pub token_id: String,
    pub price: Uint128,
}
// src/execute.rs 追加

pub fn batch_list_nft(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    items: Vec<BatchNftItem>,
) -> Result<Response, ContractError> {
    if items.is_empty() { return Err(ContractError::ZeroAmount {}); }
    if items.len() > 50 { return Err(ContractError::Std(StdError::generic_err("Batch limit 50"))); }

    let config = CONFIG.load(deps.storage)?;
    let mut response = Response::new();

    for item in &items {
        if item.price.is_zero() { return Err(ContractError::ZeroAmount {}); }
        if LISTINGS_BY_NFT.has(deps.storage, item.token_id.clone()) {
            return Err(ContractError::NftAlreadyListed { token_id: item.token_id.clone() });
        }

        let owner_info: cw721::OwnerOfResponse = deps.querier.query_wasm_smart(
            config.nft_contract.to_string(),
            &cw721::Cw721QueryMsg::OwnerOf {
                token_id: item.token_id.clone(),
                include_expired: None,
            },
        )?;
        if owner_info.owner != info.sender.to_string() {
            return Err(ContractError::NftNotOwned {});
        }

        response = response.add_message(CosmosMsg::Wasm(WasmMsg::Execute {
            contract_addr: config.nft_contract.to_string(),
            msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
                recipient: env.contract.address.to_string(),
                token_id: item.token_id.clone(),
            })?,
            funds: vec![],
        }));

        let mut cfg = CONFIG.load(deps.storage)?;
        cfg.listing_count += 1;
        let listing_id = cfg.listing_count;

        let listing = Listing {
            listing_id,
            seller: info.sender.clone(),
            nft_token_id: item.token_id.clone(),
            price: item.price,
            created_at: env.block.height,
            status: ListingStatus::Active,
        };

        LISTINGS.save(deps.storage, listing_id, &listing)?;
        LISTINGS_BY_SELLER.save(deps.storage, (info.sender.clone(), listing_id), &true)?;
        LISTINGS_BY_NFT.save(deps.storage, item.token_id.clone(), &listing_id)?;
        CONFIG.save(deps.storage, &cfg)?;

        response = response.add_attribute(format!("listing_{}", item.token_id), listing_id.to_string());
    }

    Ok(response.add_attribute("action", "batch_list_nft").add_attribute("count", items.len().to_string()))
}

5.2 合集报价 (Collection Offer)

允许买家对合集中的任意 NFT 出价,卖家可以领取:

// src/state.rs 追加

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct CollectionOffer {
    pub offer_id: u64,
    pub buyer: Addr,
    pub token_amount: Uint128,
    pub created_at: u64,
    pub expires_at: Option<u64>,
    pub status: OfferStatus,
    pub quantity: u8,
    pub claimed: u8,
}

pub const COLLECTION_OFFERS: Map<u64, CollectionOffer> = Map::new("collection_offers");
// src/execute.rs 追加

pub fn make_collection_offer(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_amount: Uint128,
    quantity: u8,
    expires_at: Option<u64>,
) -> Result<Response, ContractError> {
    if token_amount.is_zero() || quantity == 0 { return Err(ContractError::ZeroAmount {}); }

    let config = CONFIG.load(deps.storage)?;
    let total_amount = token_amount.checked_multiply_ceil(Uint128::from(quantity as u128))?;

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

    let mut config = config;
    config.offer_count += 1;
    let offer_id = config.offer_count;

    let offer = CollectionOffer {
        offer_id,
        buyer: info.sender.clone(),
        token_amount,
        created_at: env.block.height,
        expires_at,
        status: OfferStatus::Active,
        quantity,
        claimed: 0,
    };

    COLLECTION_OFFERS.save(deps.storage, offer_id, &offer)?;
    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_attribute("action", "make_collection_offer")
        .add_attribute("offer_id", offer_id.to_string())
        .add_attribute("buyer", info.sender.as_str())
        .add_attribute("total_amount", total_amount.to_string()))
}
pub fn claim_collection_offer(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    offer_id: u64,
    token_id: String,
) -> Result<Response, ContractError> {
    let mut offer = COLLECTION_OFFERS
        .load(deps.storage, offer_id)
        .map_err(|_| ContractError::OfferNotFound { offer_id })?;

    if offer.status != OfferStatus::Active { return Err(ContractError::OfferNotActive {}); }
    if offer.claimed >= offer.quantity { return Err(ContractError::AlreadyExecuted {}); }

    if let Some(exp) = offer.expires_at {
        if env.block.height > exp {
            offer.status = OfferStatus::Cancelled;
            COLLECTION_OFFERS.save(deps.storage, offer_id, &offer)?;
            return Err(ContractError::OfferExpired {});
        }
    }

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

    let owner_info: cw721::OwnerOfResponse = deps.querier.query_wasm_smart(
        config.nft_contract.to_string(),
        &cw721::Cw721QueryMsg::OwnerOf {
            token_id: token_id.clone(),
            include_expired: None,
        },
    )?;
    if owner_info.owner != info.sender.to_string() {
        return Err(ContractError::NftNotOwned {});
    }

    let fee = calc_fee(offer.token_amount, config.fee_percent);
    let net_amount = calc_net_amount(offer.token_amount, config.fee_percent);

    offer.claimed += 1;
    if offer.claimed >= offer.quantity { offer.status = OfferStatus::Accepted; }
    COLLECTION_OFFERS.save(deps.storage, offer_id, &offer)?;

    let mut config = config;
    config.total_volume = config.total_volume.checked_add(offer.token_amount)?;
    config.total_fees = config.total_fees.checked_add(fee)?;
    CONFIG.save(deps.storage, &config)?;

    Ok(Response::new()
        .add_message(CosmosMsg::Wasm(WasmMsg::Execute {
            contract_addr: config.token_contract.to_string(),
            msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
                recipient: info.sender.to_string(),
                amount: net_amount,
            })?,
            funds: vec![],
        }))
        .add_message(CosmosMsg::Wasm(WasmMsg::Execute {
            contract_addr: config.token_contract.to_string(),
            msg: to_json_binary(&Cw20ExecuteMsg::Transfer {
                recipient: config.fee_address.to_string(),
                amount: fee,
            })?,
            funds: vec![],
        }))
        .add_message(CosmosMsg::Wasm(WasmMsg::Execute {
            contract_addr: config.nft_contract.to_string(),
            msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
                recipient: offer.buyer.to_string(),
                token_id,
            })?,
            funds: vec![],
        }))
        .add_attribute("action", "claim_collection_offer")
        .add_attribute("offer_id", offer_id.to_string())
        .add_attribute("claimed", offer.claimed.to_string())
        .add_attribute("buyer", offer.buyer.as_str())
        .add_attribute("seller", info.sender.as_str()))
}

5.3 特征定价

// src/execute.rs — 基于特征的批量定价

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct TraitPricingRule {
    pub trait_type: String,
    pub value: String,
    pub price_multiplier: Decimal,
}

pub fn list_with_trait_pricing(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    base_price: Uint128,
    rules: Vec<TraitPricingRule>,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;

    let nft_info: cw721::NftInfoResponse<serde_json::Value> = deps.querier.query_wasm_smart(
        config.nft_contract.to_string(),
        &cw721::Cw721QueryMsg::NftInfo { token_id: token_id.clone() },
    )?;

    let attributes = nft_info.extension
        .get("attributes")
        .and_then(|a| a.as_array())
        .map(|arr| {
            arr.iter().filter_map(|attr| {
                Some((
                    attr.get("trait_type")?.as_str()?.to_string(),
                    attr.get("value")?.as_str()?.to_string(),
                ))
            }).collect::<Vec<_>>()
        })
        .unwrap_or_default();

    let mut final_price = base_price;
    for (trait_type, value) in &attributes {
        for rule in &rules {
            if rule.trait_type == *trait_type && rule.value == *value {
                final_price = decimal_multiplication(final_price, rule.price_multiplier);
            }
        }
    }

    list_nft(deps, env, info, token_id, final_price)
}

5.4 稀有度评分集成

#!/usr/bin/env python3
"""稀有度评分计算器"""

from typing import Dict, List, Optional
from dataclasses import dataclass
from collections import Counter
import math

@dataclass
class Trait:
    trait_type: str
    value: str

@dataclass
class NFT:
    token_id: str
    traits: List[Trait]

class RarityCalculator:
    def __init__(self):
        self.nfts: Dict[str, NFT] = {}
        self.trait_counts: Dict[str, Dict[str, int]] = {}
        self.total_nfts: int = 0

    def load_nfts(self, nfts: List[NFT]):
        self.nfts = {n.token_id: n for n in nfts}
        self.total_nfts = len(nfts)
        self._compute_trait_counts()

    def _compute_trait_counts(self):
        self.trait_counts = {}
        for nft in self.nfts.values():
            for trait in nft.traits:
                if trait.trait_type not in self.trait_counts:
                    self.trait_counts[trait.trait_type] = Counter()
                self.trait_counts[trait.trait_type][trait.value] += 1

    def statistical_rarity(self, token_id: str) -> float:
        nft = self.nfts.get(token_id)
        if not nft: return 0.0
        score = 1.0
        for trait in nft.traits:
            count = self.trait_counts.get(trait.trait_type, {}).get(trait.value, 0)
            freq = count / self.total_nfts if self.total_nfts > 0 else 1
            score *= freq
        return 1.0 / score if score > 0 else 0.0
    def trait_rarity_score(self, token_id: str) -> float:
        nft = self.nfts.get(token_id)
        if not nft or not nft.traits: return 0.0
        total_score = 0.0
        for trait in nft.traits:
            count = self.trait_counts.get(trait.trait_type, {}).get(trait.value, 0)
            freq = count / self.total_nfts if self.total_nfts > 0 else 1
            total_score += 1.0 / freq
        return total_score / len(nft.traits)

    def normalized_rarity(self, token_id: str) -> float:
        scores = [self.trait_rarity_score(tid) for tid in self.nfts]
        if not scores: return 0.0
        mean = sum(scores) / len(scores)
        variance = sum((s - mean) ** 2 for s in scores) / len(scores)
        std_dev = math.sqrt(variance)
        if std_dev == 0: return 50.0
        token_score = self.trait_rarity_score(token_id)
        z_score = (token_score - mean) / std_dev
        normalized = (z_score + 3) / 6 * 100
        return max(0, min(100, normalized))

    def suggest_price(self, token_id: str, base_price: float) -> float:
        rarity = self.normalized_rarity(token_id)
        multiplier = 0.5 + (rarity / 100) * 2.5
        return base_price * multiplier

    def get_trait_summary(self) -> Dict:
        summary = {}
        for trait_type, values in self.trait_counts.items():
            total = sum(values.values())
            summary[trait_type] = {
                "unique_values": len(values),
                "rarest": min(values, key=values.get) if values else None,
                "most_common": max(values, key=values.get) if values else None,
                "distribution": {
                    val: {"count": count, "frequency": f"{count / total * 100:.2f}%"}
                    for val, count in values.most_common()
                },
            }
        return summary

if __name__ == "__main__":
    samples = [
        NFT("001", [Trait("背景", "红色"), Trait("眼睛", "蓝色"), Trait("等级", "稀有")]),
        NFT("002", [Trait("背景", "蓝色"), Trait("眼睛", "绿色"), Trait("等级", "普通")]),
        NFT("003", [Trait("背景", "红色"), Trait("眼睛", "红色"), Trait("等级", "史诗")]),
        NFT("004", [Trait("背景", "绿色"), Trait("眼睛", "蓝色"), Trait("等级", "普通")]),
        NFT("005", [Trait("背景", "金色"), Trait("眼睛", "紫色"), Trait("等级", "传说")]),
    ]
    calc = RarityCalculator()
    calc.load_nfts(samples)
    for nft in samples:
        r = calc.normalized_rarity(nft.token_id)
        p = calc.suggest_price(nft.token_id, 100.0)
        print(f"NFT #{nft.token_id}: 稀有度={r:.1f}/100, 建议价={p:.2f} MSG")

5.5 TypeScript 稀有度前端集成

// src/lib/rarity.ts

export interface Trait { trait_type: string; value: string }
export interface NftWithTraits { tokenId: string; traits: Trait[] }

export class RarityClient {
  private nfts: Map<string, NftWithTraits> = new Map();
  private traitCounts: Map<string, Map<string, number>> = new Map();
  private totalNfts = 0;

  loadNfts(nfts: NftWithTraits[]): void {
    this.nfts.clear();
    this.traitCounts.clear();
    for (const nft of nfts) { this.nfts.set(nft.tokenId, nft); }
    this.totalNfts = nfts.length;
    this.computeTraitCounts();
  }

  private computeTraitCounts(): void {
    for (const nft of this.nfts.values()) {
      for (const trait of nft.traits) {
        if (!this.traitCounts.has(trait.trait_type)) {
          this.traitCounts.set(trait.trait_type, new Map());
        }
        const m = this.traitCounts.get(trait.trait_type)!;
        m.set(trait.value, (m.get(trait.value) || 0) + 1);
      }
    }
  }

  getTraitRarityScore(tokenId: string): number {
    const nft = this.nfts.get(tokenId);
    if (!nft || nft.traits.length === 0) return 0;
    let totalScore = 0;
    for (const trait of nft.traits) {
      const count = this.traitCounts.get(trait.trait_type)?.get(trait.value) || 0;
      const freq = this.totalNfts > 0 ? count / this.totalNfts : 1;
      totalScore += 1 / freq;
    }
    return totalScore / nft.traits.length;
  }

  getNormalizedRarity(tokenId: string): number {
    const scores = Array.from(this.nfts.keys()).map(id => this.getTraitRarityScore(id));
    if (scores.length === 0) return 0;
    const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
    const variance = scores.reduce((acc, s) => acc + (s - mean) ** 2, 0) / scores.length;
    const stdDev = Math.sqrt(variance);
    if (stdDev === 0) return 50;
    const zScore = (this.getTraitRarityScore(tokenId) - mean) / stdDev;
    return Math.max(0, Math.min(100, ((zScore + 3) / 6) * 100));
  }

  getRarityRank(tokenId: string): { rank: number; total: number } {
    const scores = Array.from(this.nfts.entries())
      .map(([id]) => ({ id, score: this.getNormalizedRarity(id) }))
      .sort((a, b) => b.score - a.score);
    const rank = scores.findIndex(s => s.id === tokenId) + 1;
    return { rank, total: scores.length };
  }

  suggestPrice(tokenId: string, floorPrice: number): number {
    const r = this.getNormalizedRarity(tokenId);
    return floorPrice * (0.5 + (r / 100) * 2.5);
  }
}

6. 安全

6.1 NFT 所有权验证

在 accept_offer 和 list_nft 中,必须验证调用者是否确实拥有该 NFT。这是防止假卖单的核心:

pub fn verify_nft_ownership(
    deps: &DepsMut,
    nft_contract: &Addr,
    token_id: &str,
    claimed_owner: &Addr,
) -> Result<(), ContractError> {
    let owner_info: cw721::OwnerOfResponse = deps.querier.query_wasm_smart(
        nft_contract.to_string(),
        &cw721::Cw721QueryMsg::OwnerOf {
            token_id: token_id.to_string(),
            include_expired: None,
        },
    )?;

    if owner_info.owner != claimed_owner.to_string() {
        return Err(ContractError::NftNotOwned {});
    }

    // 额外验证:检查返回的 owner 是否是有效的 bech32 地址
    deps.api.addr_validate(&owner_info.owner)?;

    Ok(())
}

6.2 CW20 代币授权检查

合约依赖于 TransferFrom 来拉取代币。必须确保买家已授权:

pub fn check_allowance(
    deps: &Deps,
    token_contract: &Addr,
    owner: &Addr,
    spender: &Addr,
    required_amount: Uint128,
) -> Result<(), ContractError> {
    let allowance: cw20::AllowanceResponse = deps.querier.query_wasm_smart(
        token_contract.to_string(),
        &cw20::Cw20QueryMsg::Allowance {
            owner: owner.to_string(),
            spender: spender.to_string(),
        },
    )?;

    if allowance.allowance < required_amount {
        return Err(ContractError::InsufficientPayment {
            required: required_amount,
            received: allowance.allowance,
        });
    }

    let balance: cw20::BalanceResponse = deps.querier.query_wasm_smart(
        token_contract.to_string(),
        &cw20::Cw20QueryMsg::Balance {
            address: owner.to_string(),
        },
    )?;

    if balance.balance < required_amount {
        return Err(ContractError::InsufficientPayment {
            required: required_amount,
            received: balance.balance,
        });
    }

    Ok(())
}

6.3 防重入锁

NFT 转账可能触发回调(比如接收者合约有 OnNftReceived),需要防止重入攻击:

const LOCK: Item<bool> = Item::new("lock");

fn assert_no_lock(storage: &mut dyn Storage) -> Result<(), ContractError> {
    if LOCK.load(storage).unwrap_or(false) {
        return Err(ContractError::ReentrancyDetected {});
    }
    Ok(())
}

fn set_lock(storage: &mut dyn Storage, locked: bool) -> Result<(), ContractError> {
    LOCK.save(storage, &locked)?;
    Ok(())
}

使用模式:

pub fn accept_offer(...) -> Result<Response, ContractError> {
    assert_no_lock(deps.storage)?;   // 进入时检查锁
    set_lock(deps.storage, true)?;   // 加锁
    // ... 核心逻辑 ...
    set_lock(deps.storage, false)?;  // 解锁
    Ok(response)
}

6.4 假 NFT 检测

NFT 合约必须由合约所有者指定,且不能更改。防止攻击者部署仿冒 NFT 合约:

pub fn verify_nft_contract(
    deps: &Deps,
    expected: &Addr,
    actual: &Addr,
) -> Result<(), ContractError> {
    if expected != actual {
        return Err(ContractError::FakeNftDetected {
            contract: actual.to_string(),
            expected: expected.to_string(),
        });
    }
    Ok(())
}

NFT 合约接口检查:

pub fn validate_nft_contract(
    deps: &DepsMut,
    contract_addr: &Addr,
) -> Result<(), ContractError> {
    let contract_info: Result<cw721::ContractInfoResponse, _> = deps.querier.query_wasm_smart(
        contract_addr.to_string(),
        &cw721::Cw721QueryMsg::ContractInfo {},
    );

    match contract_info {
        Ok(info) => {
            if info.name.is_empty() {
                return Err(ContractError::Std(StdError::generic_err("Invalid NFT contract")));
            }
            Ok(())
        }
        Err(_) => Err(ContractError::Std(StdError::generic_err("Not a CW721 contract"))),
    }
}

6.5 溢出安全

使用 Uint128 和 checked arithmetic:

let total = amount.checked_add(other)?;
let diff = amount.checked_sub(other)?;
let product = amount.checked_mul(other)?;
let quotient = amount.checked_div(other)?;

// 费用计算使用 Decimal 乘法
let fee = amount * fee_percent;  // 自动使用 checked 运算
let net = amount - fee;

6.6 攻击向量与防御

攻击类型 描述 防御措施
假 NFT 挂单 部署仿冒 NFT 合约并挂单 实例化时固定合约地址,不可更改
重入攻击 NFT 转账回调触发合约再次进入 布尔锁 (LOCK) 防止重入
报价过期后成交 利用过期的报价 每次执行时检查 expires_at
授权不足 TransferFrom 因余额/授权不足失败 前端预检 allowance;合约依靠原子回滚
前端抢跑 监听交易池抢先交易 使用过期高度限制,鼓励隐私提交
稀有度操纵 虚报稀有度误导定价 链下计算稀有度,合约仅作被动执行
批量上架滥用 批量挂单 gas 攻击 限制 batch_list_nft 上限 50

6.7 前端安全检查清单

// 前端安全检查工具

export async function validateBeforeBuy(
  client: MsgChainClient,
  listingId: number,
  price: string,
  userAddress: string,
  tokenContract: string,
  swapContract: string,
): Promise<{ ok: boolean; errors: string[] }> {
  const errors: string[] = [];

  try {
    // 1. 验证挂单仍在链上活跃
    const listing = await client.queryContract(swapContract, { get_listing: { listing_id: listingId } });
    if (listing.status !== "active") {
      errors.push("该挂单已售出或撤回");
    }

    // 2. 验证买家不是卖家
    if (listing.seller === userAddress) {
      errors.push("不能购买自己的 NFT");
    }

    // 3. 验证代币授权
    const allowance = await client.queryContract(tokenContract, {
      allowance: { owner: userAddress, spender: swapContract },
    });
    if (BigInt(allowance.allowance) < BigInt(price)) {
      errors.push(`授权不足,需要 ${price},已授权 ${allowance.allowance}`);
    }

    // 4. 验证代币余额
    const balance = await client.queryContract(tokenContract, {
      balance: { address: userAddress },
    });
    if (BigInt(balance.balance) < BigInt(price)) {
      errors.push(`余额不足,需要 ${price},持有 ${balance.balance}`);
    }

    // 5. 验证价格未变动
    if (listing.price !== price) {
      errors.push(`价格已变动,原 ${price},现 ${listing.price}`);
    }
  } catch (e: any) {
    errors.push(`验证异常: ${e.message}`);
  }

  return { ok: errors.length === 0, errors };
}

6.8 费用计算与抽水

// 确保费用计算正确且不可篡改
// 费用上限在 instantiate 时校验

pub fn validate_fee_percent(fee_percent: &str) -> Result<Decimal, ContractError> {
    let fee: Decimal = fee_percent
        .parse()
        .map_err(|_| ContractError::InvalidFee {})?;

    if fee > Decimal::percent(10) {
        return Err(ContractError::InvalidFee {});
    }

    // 检查精度不超过 4 位小数 (0.01%)
    let atomics = fee.atomics();
    if atomics.u128() % 100_000_000_000_000_000u128 != 0 {
        return Err(ContractError::InvalidFee {});
    }

    Ok(fee)
}

7. 集成测试

7.1 单元测试 — 报价创建与接受

// src/tests/mod.rs

#[cfg(test)]
mod tests {
    use cosmwasm_std::{
        from_json, to_json_binary, Addr, Coin, Decimal, Empty, Uint128,
    };
    use cw_multi_test::{App, Contract, ContractWrapper, Executor};
    use crate::error::ContractError;
    use crate::msg::{
        ExecuteMsg, InstantiateMsg, ListingResponse, OfferResponse, QueryMsg,
        ConfigResponse, StatsResponse, ListingListResponse, OfferListResponse,
    };
    use crate::state::{OfferStatus, ListingStatus};

    const MSG_DENOM: &str = "umsg";
    const NATIVE_DECIMALS: u8 = 18;

    fn mock_app() -> App {
        App::new(|router, _api, storage| {
            router
                .bank
                .init_balance(
                    storage,
                    &Addr::unchecked("creator"),
                    vec![Coin::new(1_000_000_000, MSG_DENOM)],
                )
                .unwrap();
        })
    }

    fn contract_nft_swap() -> Box<dyn Contract<Empty>> {
        let contract = ContractWrapper::new(
            crate::contract::execute,
            crate::contract::instantiate,
            crate::contract::query,
        );
        Box::new(contract)
    }

    fn contract_cw20() -> Box<dyn Contract<Empty>> {
        // 使用 cw20-base 的标准合约
        let contract = ContractWrapper::new(
            cw20_base::contract::execute,
            cw20_base::contract::instantiate,
            cw20_base::contract::query,
        );
        Box::new(contract)
    }

    fn contract_cw721() -> Box<dyn Contract<Empty>> {
        let contract = ContractWrapper::new(
            cw721_base_0_18::entry::execute,
            cw721_base_0_18::entry::instantiate,
            cw721_base_0_18::entry::query,
        );
        Box::new(contract)
    }

    fn setup_contracts(app: &mut App) -> (String, String, String) {
        let cw20_id = app.store_code(contract_cw20());
        let cw721_id = app.store_code(contract_cw721());
        let swap_id = app.store_code(contract_nft_swap());

        // 实例化 CW20
        let token_contract = app
            .instantiate_contract(
                cw20_id,
                Addr::unchecked("creator"),
                &cw20_base::msg::InstantiateMsg {
                    name: "Test Token".to_string(),
                    symbol: "TST".to_string(),
                    decimals: 18,
                    initial_balances: vec![
                        cw20::Cw20Coin {
                            address: "buyer".to_string(),
                            amount: Uint128::new(10_000_000_000_000_000_000_000u128), // 10,000 TST
                        },
                        cw20::Cw20Coin {
                            address: "seller".to_string(),
                            amount: Uint128::new(5_000_000_000_000_000_000_000u128), // 5,000 TST
                        },
                    ],
                    mint: None,
                    marketing: None,
                },
                &[],
                "token",
                None,
            )
            .unwrap();

        // 实例化 CW721
        let nft_contract = app
            .instantiate_contract(
                cw721_id,
                Addr::unchecked("creator"),
                &cw721_base_0_18::msg::InstantiateMsg {
                    name: "Test NFT".to_string(),
                    symbol: "TNFT".to_string(),
                    minter: "seller".to_string(),
                },
                &[],
                "nft",
                None,
            )
            .unwrap();

        // 铸造 NFT
        app.execute_contract(
            Addr::unchecked("seller"),
            nft_contract.clone(),
            &cw721_base_0_18::msg::ExecuteMsg::<Empty, Empty>::Mint {
                token_id: "nft001".to_string(),
                owner: "seller".to_string(),
                token_uri: Some("https://example.com/nft001.json".to_string()),
                extension: Empty {},
            },
            &[],
        )
        .unwrap();

        app.execute_contract(
            Addr::unchecked("seller"),
            nft_contract.clone(),
            &cw721_base_0_18::msg::ExecuteMsg::<Empty, Empty>::Mint {
                token_id: "nft002".to_string(),
                owner: "seller".to_string(),
                token_uri: Some("https://example.com/nft002.json".to_string()),
                extension: Empty {},
            },
            &[],
        )
        .unwrap();

        // 实例化 Swap Pool
        let swap_contract = app
            .instantiate_contract(
                swap_id,
                Addr::unchecked("creator"),
                &InstantiateMsg {
                    nft_contract: nft_contract.to_string(),
                    token_contract: token_contract.to_string(),
                    fee_percent: "0.02".to_string(),
                    fee_address: Some("fee_collector".to_string()),
                },
                &[],
                "swap",
                None,
            )
            .unwrap();

        // 买家批准 CW20 代币给 swap 合约
        app.execute_contract(
            Addr::unchecked("buyer"),
            token_contract.clone(),
            &cw20_base::msg::ExecuteMsg::IncreaseAllowance {
                spender: swap_contract.to_string(),
                amount: Uint128::new(10_000_000_000_000_000_000_000u128),
                expires: None,
            },
            &[],
        )
        .unwrap();

        (swap_contract.to_string(), token_contract.to_string(), nft_contract.to_string())
    }

    #[test]
    fn test_make_and_accept_offer() {
        let mut app = mock_app();
        let (swap, token, nft) = setup_contracts(&mut app);

        // 买家创建报价
        app.execute_contract(
            Addr::unchecked("buyer"),
            Addr::unchecked(&swap),
            &ExecuteMsg::MakeOffer {
                token_amount: Uint128::new(1_000_000_000_000_000_000_000u128), // 1,000 TST
                nft_token_id: "nft001".to_string(),
                expires_at: None,
            },
            &[],
        )
        .unwrap();

        // 查询报价
        let offers: OfferListResponse = app
            .wrap()
            .query_wasm_smart(swap.clone(), &QueryMsg::ListOffers {
                start_after: None,
                limit: None,
            })
            .unwrap();

        assert_eq!(offers.offers.len(), 1);
        assert_eq!(offers.offers[0].buyer, "buyer");
        assert_eq!(offers.offers[0].status, "active");

        // 卖家接受报价
        app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&swap),
            &ExecuteMsg::AcceptOffer { offer_id: 1 },
            &[],
        )
        .unwrap();

        // 验证报价状态
        let offer: OfferResponse = app
            .wrap()
            .query_wasm_smart(swap.clone(), &QueryMsg::GetOffer { offer_id: 1 })
            .unwrap();
        assert_eq!(offer.status, "accepted");

        // 验证买家收到了 NFT
        let owner: cw721::OwnerOfResponse = app
            .wrap()
            .query_wasm_smart(nft.clone(), &cw721::Cw721QueryMsg::OwnerOf {
                token_id: "nft001".to_string(),
                include_expired: None,
            })
            .unwrap();
        assert_eq!(owner.owner, "buyer");

        // 验证卖家收到了代币 (扣除 2% 手续费)
        let seller_balance: cw20::BalanceResponse = app
            .wrap()
            .query_wasm_smart(token.clone(), &cw20::Cw20QueryMsg::Balance {
                address: "seller".to_string(),
            })
            .unwrap();
        // 最初有 5,000 TST,卖出得 1,000 TST 扣除 2% = 980 TST
        assert_eq!(
            seller_balance.balance,
            Uint128::new(5_000_000_000_000_000_000_000u128) // 5,000 + 980 = 5980
                + Uint128::new(980_000_000_000_000_000_000u128)
        );
    }
}

7.2 单元测试 — 挂单与购买

    #[test]
    fn test_list_and_buy_nft() {
        let mut app = mock_app();
        let (swap, token, nft) = setup_contracts(&mut app);

        // 卖家挂单前需要批准 NFT 给 swap 合约
        // 注意: list_nft 会从卖家 Transfer NFT,需要先 approve
        app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&nft),
            &cw721_base_0_18::msg::ExecuteMsg::<Empty, Empty>::Approve {
                spender: swap.to_string(),
                token_id: "nft002".to_string(),
                expires: None,
            },
            &[],
        )
        .unwrap();

        // 卖家挂单
        app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&swap),
            &ExecuteMsg::ListNft {
                token_id: "nft002".to_string(),
                price: Uint128::new(2_000_000_000_000_000_000_000u128), // 2,000 TST
            },
            &[],
        )
        .unwrap();

        // 查询挂单
        let listings: ListingListResponse = app
            .wrap()
            .query_wasm_smart(swap.clone(), &QueryMsg::ListListings {
                start_after: None,
                limit: None,
            })
            .unwrap();

        assert_eq!(listings.listings.len(), 1);
        assert_eq!(listings.listings[0].seller, "seller");
        assert_eq!(listings.listings[0].status, "active");

        // 买家购买
        app.execute_contract(
            Addr::unchecked("buyer"),
            Addr::unchecked(&swap),
            &ExecuteMsg::BuyNft { listing_id: 1 },
            &[],
        )
        .unwrap();

        // 验证挂单状态
        let listing: ListingResponse = app
            .wrap()
            .query_wasm_smart(swap.clone(), &QueryMsg::GetListing { listing_id: 1 })
            .unwrap();
        assert_eq!(listing.status, "sold");

        // 验证 NFT 已转移给买家
        let owner: cw721::OwnerOfResponse = app
            .wrap()
            .query_wasm_smart(nft.clone(), &cw721::Cw721QueryMsg::OwnerOf {
                token_id: "nft002".to_string(),
                include_expired: None,
            })
            .unwrap();
        assert_eq!(owner.owner, "buyer");
    }

    #[test]
    fn test_cancel_offer() {
        let mut app = mock_app();
        let (swap, token, _) = setup_contracts(&mut app);

        app.execute_contract(
            Addr::unchecked("buyer"),
            Addr::unchecked(&swap),
            &ExecuteMsg::MakeOffer {
                token_amount: Uint128::new(500_000_000_000_000_000_000u128), // 500 TST
                nft_token_id: "nft001".to_string(),
                expires_at: None,
            },
            &[],
        )
        .unwrap();

        // 买家取消报价
        app.execute_contract(
            Addr::unchecked("buyer"),
            Addr::unchecked(&swap),
            &ExecuteMsg::CancelOffer { offer_id: 1 },
            &[],
        )
        .unwrap();

        let offer: OfferResponse = app
            .wrap()
            .query_wasm_smart(swap.clone(), &QueryMsg::GetOffer { offer_id: 1 })
            .unwrap();
        assert_eq!(offer.status, "cancelled");
    }

7.3 安全测试

    #[test]
    fn test_unauthorized_accept() {
        let mut app = mock_app();
        let (swap, _, _) = setup_contracts(&mut app);

        // 买家创建报价
        app.execute_contract(
            Addr::unchecked("buyer"),
            Addr::unchecked(&swap),
            &ExecuteMsg::MakeOffer {
                token_amount: Uint128::new(500_000_000_000_000_000_000u128),
                nft_token_id: "nft001".to_string(),
                expires_at: None,
            },
            &[],
        )
        .unwrap();

        // 非持有人尝试接受报价
        let err = app.execute_contract(
            Addr::unchecked("attacker"),
            Addr::unchecked(&swap),
            &ExecuteMsg::AcceptOffer { offer_id: 1 },
            &[],
        )
        .unwrap_err();

        assert!(err
            .source()
            .unwrap()
            .to_string()
            .contains("NFT not owned"));
    }

    #[test]
    fn test_double_accept() {
        let mut app = mock_app();
        let (swap, _, _) = setup_contracts(&mut app);

        app.execute_contract(
            Addr::unchecked("buyer"),
            Addr::unchecked(&swap),
            &ExecuteMsg::MakeOffer {
                token_amount: Uint128::new(1_000_000_000_000_000_000_000u128),
                nft_token_id: "nft001".to_string(),
                expires_at: None,
            },
            &[],
        )
        .unwrap();

        app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&swap),
            &ExecuteMsg::AcceptOffer { offer_id: 1 },
            &[],
        )
        .unwrap();

        // 再次尝试接受同一报价
        let err = app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&swap),
            &ExecuteMsg::AcceptOffer { offer_id: 1 },
            &[],
        )
        .unwrap_err();

        assert!(err
            .source()
            .unwrap()
            .to_string()
            .contains("Offer is not active"));
    }

    #[test]
    fn test_withdraw_nft() {
        let mut app = mock_app();
        let (swap, _, nft) = setup_contracts(&mut app);

        app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&nft),
            &cw721_base_0_18::msg::ExecuteMsg::<Empty, Empty>::Approve {
                spender: swap.to_string(),
                token_id: "nft001".to_string(),
                expires: None,
            },
            &[],
        )
        .unwrap();

        app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&swap),
            &ExecuteMsg::ListNft {
                token_id: "nft001".to_string(),
                price: Uint128::new(1_000_000_000_000_000_000_000u128),
            },
            &[],
        )
        .unwrap();

        app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&swap),
            &ExecuteMsg::WithdrawNft { listing_id: 1 },
            &[],
        )
        .unwrap();

        let owner: cw721::OwnerOfResponse = app
            .wrap()
            .query_wasm_smart(nft, &cw721::Cw721QueryMsg::OwnerOf {
                token_id: "nft001".to_string(),
                include_expired: None,
            })
            .unwrap();
        assert_eq!(owner.owner, "seller");
    }
}

7.4 集成测试 — 端到端场景

    #[test]
    fn test_full_marketplace_scenario() {
        let mut app = mock_app();
        let (swap, token, nft) = setup_contracts(&mut app);

        // 场景: 多个报价 + 挂单 + 成交 + 取消

        // Step 1: 买家1 和 买家2 对 nft001 出价
        app.execute_contract(
            Addr::unchecked("buyer"),
            Addr::unchecked(&swap),
            &ExecuteMsg::MakeOffer {
                token_amount: Uint128::new(800_000_000_000_000_000_000u128),
                nft_token_id: "nft001".to_string(),
                expires_at: None,
            },
            &[],
        )
        .unwrap();

        // 批准第二个买家的 allowance
        app.execute_contract(
            Addr::unchecked("buyer2"),
            Addr::unchecked(&token),
            &cw20_base::msg::ExecuteMsg::IncreaseAllowance {
                spender: swap.to_string(),
                amount: Uint128::new(10_000_000_000_000_000_000_000u128),
                expires: None,
            },
            &[],
        )
        .unwrap();

        app.execute_contract(
            Addr::unchecked("buyer2"),
            Addr::unchecked(&swap),
            &ExecuteMsg::MakeOffer {
                token_amount: Uint128::new(1_200_000_000_000_000_000_000u128),
                nft_token_id: "nft001".to_string(),
                expires_at: None,
            },
            &[],
        )
        .unwrap();

        // Step 2: 卖家接受最高价 (买家2的 1,200)
        app.execute_contract(
            Addr::unchecked("seller"),
            Addr::unchecked(&swap),
            &ExecuteMsg::AcceptOffer { offer_id: 2 },
            &[],
        )
        .unwrap();

        // Step 3: 验证统计
        let stats: StatsResponse = app
            .wrap()
            .query_wasm_smart(swap.clone(), &QueryMsg::GetStats {})
            .unwrap();
        assert_eq!(stats.total_volume, Uint128::new(1_200_000_000_000_000_000_000u128));

        // Step 4: 检查活跃报价 (买家的报价应仍在,但因 NFT 已转走他可取消)
        let offers: OfferListResponse = app
            .wrap()
            .query_wasm_smart(swap.clone(), &QueryMsg::ListOffers {
                start_after: None, limit: None,
            })
            .unwrap();
        assert_eq!(offers.offers.len(), 1); // 只有买家1的报价还在

        // Step 5: 配置查询
        let config: ConfigResponse = app
            .wrap()
            .query_wasm_smart(swap, &QueryMsg::GetConfig {})
            .unwrap();
        assert_eq!(config.fee_percent, "0.02");
    }
}

7.5 Python 测试脚本

#!/usr/bin/env python3
"""集成测试脚本 — 使用 CosmWasm Python SDK 进行端到端测试"""

import asyncio
import json

# 伪代码示例 — 实际需要 Cosmos SDK 或 pystarport 环境

async def test_make_offer():
    """测试创建报价流程"""
    # 1. 连接 MSG Chain
    # client = await CosmWasmClient.connect("https://rpc.msg-chain-1.com")

    # 2. 查询当前报价
    # offers = await client.query_contract_smart(
    #     CONTRACT,
    #     {"list_offers": {"start_after": None, "limit": 10}}
    # )
    # assert len(offers["offers"]) >= 0

    # 3. 构建交易
    # tx = {
    #     "make_offer": {
    #         "token_amount": "1000000000000000000",
    #         "nft_token_id": "nft001",
    #         "expires_at": None,
    #     }
    # }
    print("测试 make_offer: OK")

async def test_accept_offer():
    """测试接受报价"""
    print("测试 accept_offer: OK")

async def test_cancel_offer():
    """测试取消报价"""
    print("测试 cancel_offer: OK")

async def test_full_flow():
    """完整流程测试: 报价 -> 接受 -> 验证"""
    print("=" * 40)
    print("端到端测试用例")
    print("=" * 40)

    # 测试用例
    cases = [
        ("买家创建报价", test_make_offer),
        ("卖家接受报价", test_accept_offer),
        ("买家取消报价", test_cancel_offer),
    ]

    for name, test_fn in cases:
        try:
            await test_fn()
            print(f"  ✓ {name}")
        except Exception as e:
            print(f"  ✗ {name}: {e}")

    print("=" * 40)

if __name__ == "__main__":
    asyncio.run(test_full_flow())

8. 部署与运维

8.1 合约部署

# 编译合约
docker run --rm -v "$(pwd)":/code \
  --mount type=volume,source="$(basename "$(pwd)")_cache",target=/target \
  --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \
  cosmwasm/workspace-optimizer:0.15.0

# 上传合约
BINARY="artifacts/nft_swap_pool.wasm"
CHAIN_ID="msg-chain-1"
RPC="https://rpc.msg-chain-1.com"

# 使用 wasmd 或 MsgChain CLI
RESP=$(msgd tx wasm store "$BINARY" \
  --from deployer \
  --chain-id "$CHAIN_ID" \
  --node "$RPC" \
  --gas auto --gas-adjustment 1.3 \
  --fees 500umsg \
  -y --output json)

CODE_ID=$(echo "$RESP" | jq -r '.logs[0].events[] | select(.type=="store_code") | .attributes[] | select(.key=="code_id") | .value')
echo "Code ID: $CODE_ID"

# 实例化合约
INIT='{
  "nft_contract": "msg1nft...",
  "token_contract": "msg1token...",
  "fee_percent": "0.02",
  "fee_address": "msg1fee..."
}'

msgd tx wasm instantiate "$CODE_ID" "$INIT" \
  --from deployer \
  --label "nft-swap-pool-v1" \
  --chain-id "$CHAIN_ID" \
  --node "$RPC" \
  --gas auto --gas-adjustment 1.3 \
  --fees 500umsg \
  --admin "$(msgd keys show deployer -a)" \
  -y --output json

# 获取合约地址
CONTRACT=$(msgd query wasm list-contract-by-code "$CODE_ID" --node "$RPC" --output json | jq -r '.contracts[-1]')
echo "Contract Address: $CONTRACT"

8.2 合约验证

# 验证代码哈希
SHA256=$(sha256sum artifacts/nft_swap_pool.wasm | cut -d' ' -f1)
ON_CHAIN_HASH=$(msgd query wasm code-info "$CODE_ID" --node "$RPC" --output json | jq -r '.data_hash')
echo "Local hash:    $SHA256"
echo "On-chain hash: $ON_CHAIN_HASH"

if [ "$SHA256" == "$ON_CHAIN_HASH" ]; then
  echo "✓ 代码哈希匹配"
else
  echo "✗ 代码哈希不匹配!"
fi

8.3 前端部署

# 构建前端
npm run build

# 使用 IPFS 或传统服务器部署
# 例如部署到 IPFS
npx ipfs-deploy build/

# 或使用常规 Web 服务器
npx serve -s build -l 3000

8.4 监控指南

// src/lib/monitor.ts — 链上事件监听

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

export class MarketplaceMonitor {
  private client: CosmWasmClient;
  private contractAddr: string;

  constructor(rpcUrl: string, contractAddr: string) {
    this.client = new CosmWasmClient(rpcUrl);
    this.contractAddr = contractAddr;
  }

  async startListening() {
    // 使用 WebSocket 订阅交易事件
    // 实际实现取决于节点是否支持 WS

    // 轮询方式
    setInterval(async () => {
      const stats = await this.client.queryContractSmart(
        this.contractAddr,
        { get_stats: {} }
      );
      console.log(`[${new Date().toISOString()}] 活跃报价: ${stats.active_offers}, 活跃挂单: ${stats.active_listings}, 总交易量: ${stats.total_volume}`);
    }, 30000);
  }

  // 监听特定事件
  async onSwap(handler: (event: any) => void) {
    // 查询历史交易
    const txs = await this.client.searchTx([
      { key: "wasm._contract_address", value: this.contractAddr },
      { key: "wasm.action", value: "accept_offer" },
    ]);
    for (const tx of txs) {
      handler(tx);
    }
  }
}

8.5 常见问题排查

问题 原因 解决方法
InsufficientPayment 代币授权或余额不足 前端预检 allowance
OfferNotActive 报价已过期或已成交 提示用户刷新页面
NftNotOwned 卖家不再拥有该 NFT 提示用户确认所有权
ReentrancyDetected 并发交易冲突 等待上一个交易确认后再试
交易 gas 不足 复杂操作消耗较多 gas 增加 gas 限制或调整 gas_adjustment

附录 A: MSG Chain 测试网水龙头

# 获取测试代币 (示例)
curl -X POST https://faucet.msg-chain-1.com/claim \
  -H "Content-Type: application/json" \
  -d '{"address": "msg1your_address_here"}'

附录 B: 完整合约文件树

nft-swap-pool/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── contract.rs      # 入口点
│   ├── msg.rs           # 消息定义
│   ├── error.rs         # 错误类型
│   ├── state.rs         # 状态存储
│   ├── execute.rs       # 执行逻辑
│   ├── query.rs         # 查询逻辑
│   ├── math.rs          # 数学工具
│   └── tests/
│       └── mod.rs       # 集成测试
├── artifacts/           # 编译产物
└── schema/              # 自动生成的 schema

附录 C: 参考文献

资源 链接
CosmWasm 文档 https://docs.cosmwasm.com
CW20 规范 https://github.com/CosmWasm/cw-plus/blob/main/packages/cw20/README.md
CW721 规范 https://github.com/CosmWasm/cw-nfts/tree/main/packages/cw721
MSG Chain https://docs.msg-chain.com
CosmJS https://github.com/cosmos/cosmjs
cw-multi-test https://docs.cosmwasm.com/docs/1.0/architecture/multi-test

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

本文档基于 MSG Chain (msg-chain-1, bech32: msg, 18 decimals) 编写。
合约代码使用 CosmWasm 1.5+,前端使用 React 18 + CosmJS 0.32+。