CW20-CW721 Token-NFT 兑换池实现指南 (MSG Chain)
目标链:
msg-chain-1| Bech32 前缀:msg| 精度: 18 decimals
主网状态: No-Go本文档提供从零开始构建基于订单簿的 Token-NFT 兑换池的完整指南,包含合约代码、前端集成、高级功能和安全最佳实践。
目录
1. 概述
1.1 为什么需要 Token-NFT 兑换池
在 Cosmos 生态中,CW20 代币和 CW721 NFT 是两类最基础的资产标准。然而,它们之间的原子交换缺乏原生基础设施。一个专用的兑换池合约可以解决以下问题:
- NFT 定价难题: 地板价难以在链上实时反映,通过订单簿可以聚合买卖双方的真实报价。
- 流动性割裂: NFT 市场流动性天然分散,兑换池充当做市商角色,降低买卖价差。
- 组合性: 允许其他 DeFi 协议(如借贷、聚合器)通过合约接口与 NFT 流动性交互。
1.2 应用场景
| 场景 | 描述 |
|---|---|
| NFT 定价 | 以 CW20 代币为计价单位,任何 NFT 可挂单出售 |
| 限价订单 | 买家对特定 NFT 出价,卖家决定是否接受 |
| 流动性提供 | LP 存入代币作为报价资金,赚取交易手续费 |
| 稀有度套利 | 基于稀有度评分的差异化定价策略 |
| 批量操作 | 一次上架多个 NFT,或对合集出价 |
1.3 设计选择:订单簿而非 AMM
NFT 的核心属性是非同质性——每个 NFT 都是唯一的。这与可互换代币有本质区别:
- AMM 的局限性: 恒定乘积做市商 (x*y=k) 适用于同质化资产,无法处理 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 是不可分割的,但从报价簿角度,"部分成交"意味着:
- 报价的部分接受: 如果一个报价针对某个 NFT,但卖家想按比例成交,这通常不适用于 NFT(NFT 是原子的)。
- 挂单的多次购买: 不能分割一个 NFT。
- 聚合流动性: 多个买家对同一个 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+。
