dApp Docs/NFT铸造与市场完整dApp
Development reference. Not independently verified for production.

MSG Chain NFT 铸造与市场完整 dApp 指南

本指南详细讲解如何在 MSG Chain 上构建一个完整的 NFT 铸造与市场 dApp,包含智能合约(Rust/CosmWasm)、前端(React/TypeScript/Tailwind)、元数据存储及部署。整体架构类似 OpenSea 与 Stargaze 的结合。
主网状态:No-Go


目录

  1. 环境概述与链配置
  2. Part 1: NFT 智能合约(cw721)
  3. Part 2: 市场智能合约
  4. Part 3: 前端(React + TypeScript + Tailwind)
  5. Part 4: 元数据与存储
  6. Part 5: 部署

1. 环境概述与链配置

MSG Chain 参数

参数 值
Chain ID msg-chain-1
Bech32 前缀 msg
CoinType 118
MSG 小数位数 18
Gas 低价 0.01 umsg
Gas 均价 1,000,000,000 attoMSG
Gas 高价 0.04 umsg
出块时间 5 秒

可用原语

开发环境准备

# 安装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 添加 WASM 目标
rustup target add wasm32-unknown-unknown

# 安装 CosmWasm 优化器
cargo install cosmwasm-check

# 安装 Node.js 依赖
npm install -g @cosmjs/cli

2. Part 1: NFT 智能合约(cw721)

项目结构

nft-contract/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── msg.rs
│   ├── state.rs
│   ├── contract.rs
│   ├── error.rs
│   ├── royalties.rs
│   └── helpers.rs
└── examples/
    └── schema.rs

Cargo.toml

[package]
name = "msg-nft"
version = "1.0.0"
edition = "2021"
description = "MSG Chain NFT 合约,基于 cw721"

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

[features]
default = ["library"]
library = []

[dependencies]
cosmwasm-std = { version = "1.5", features = ["staking"] }
cosmwasm-schema = "1.5"
cw-storage-plus = "1.2"
cw721 = "0.18"
cw721-base = { version = "0.18", features = ["library"] }
cw721-metadata-onchain = "0.18"
cw-utils = "1.0"
cosmwasm-crypto = "1.5"
cw2 = "0.18"
schemars = "0.8"
serde = { version = "1.0", default-features = false, features = ["derive"] }
thiserror = "1.0"

[dev-dependencies]
cw-multi-test = "0.18"

[profile.release]
opt-level = 3
debug = false
rpath = false
lto = true
overflow-checks = true
codegen-units = 1

src/lib.rs

pub mod msg;
pub mod state;
pub mod contract;
pub mod error;
pub mod royalties;
pub mod helpers;

#[cfg(test)]
mod tests;

src/msg.rs

use cosmwasm_schema::cw_serde;
use cosmwasm_std::{Addr, Coin, Uint128, Binary};
use cw721::Cw721ExecuteMsg;

#[cw_serde]
pub struct InstantiateMsg {
    pub name: String,
    pub symbol: String,
    pub minter: String,
    pub collection_info: CollectionInfo,
}

#[cw_serde]
pub struct CollectionInfo {
    pub creator: String,
    pub description: String,
    pub image: String,
    pub external_link: Option<String>,
    pub royalty_info: Option<RoyaltyInfo>,
}

#[cw_serde]
pub struct RoyaltyInfo {
    pub payment_address: String,
    pub share: Uint128,
}

#[cw_serde]
pub enum ExecuteMsg {
    Mint(MintMsg),
    Burn { token_id: String },
    TransferNft { recipient: String, token_id: String },
    SendNft { contract: String, token_id: String, msg: Binary },
    Approve { spender: String, token_id: String },
    ApproveAll { operator: String },
    Revoke { spender: String, token_id: String },
    RevokeAll {},
    LockToken { token_id: String },
    UpdateRoyalty { token_id: String, payment_address: String, share: Uint128 },
}

#[cw_serde]
pub struct MintMsg {
    pub token_id: String,
    pub owner: String,
    pub token_uri: Option<String>,
    pub extension: MetadataExtension,
}

#[cw_serde]
pub struct MetadataExtension {
    pub name: Option<String>,
    pub description: Option<String>,
    pub image: Option<String>,
    pub image_data: Option<String>,
    pub external_url: Option<String>,
    pub animation_url: Option<String>,
    pub youtube_url: Option<String>,
    pub attributes: Option<Vec<Trait>>,
    pub background_color: Option<String>,
    pub royalty_info: Option<RoyaltyInfo>,
    pub is_soulbound: Option<bool>,
}

#[cw_serde]
pub struct Trait {
    pub display_type: Option<String>,
    pub trait_type: String,
    pub value: String,
    pub max_value: Option<String>,
}

#[cw_serde]
pub enum QueryMsg {
    OwnerOf { token_id: String, include_expired: Option<bool> },
    Approval { token_id: String, spender: String, include_expired: Option<bool> },
    Approvals { token_id: String, include_expired: Option<bool> },
    AllOperators { owner: String, include_expired: Option<bool>, start_after: Option<String>, limit: Option<u32> },
    NumTokens {},
    CollectionInfo {},
    AllTokens { start_after: Option<String>, limit: Option<u32> },
    Minter {},
    Tokens { owner: String, start_after: Option<String>, limit: Option<u32> },
    NftInfo { token_id: String },
    AllNftInfo { token_id: String },
    ContractInfo {},
    RoyaltyInfo { token_id: String, sale_price: Uint128 },
    IsSoulbound { token_id: String },
    TokensByCreator { creator: String, start_after: Option<String>, limit: Option<u32> },
}

#[cw_serde]
pub struct RoyaltyInfoResponse {
    pub address: String,
    pub share: Uint128,
}

#[cw_serde]
pub struct TokensByCreatorResponse {
    pub tokens: Vec<String>,
}

impl From<MintMsg> for cw721_base::MintMsg<MetadataExtension> {
    fn from(msg: MintMsg) -> Self {
        cw721_base::MintMsg {
            token_id: msg.token_id,
            owner: msg.owner,
            token_uri: msg.token_uri,
            extension: msg.extension,
        }
    }
}

src/state.rs

use cw_storage_plus::{Item, Map};
use cosmwasm_std::{Addr, Uint128};
use serde::{Deserialize, Serialize};
use crate::msg::{CollectionInfo, RoyaltyInfo};

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct TokenRoyalty {
    pub token_id: String,
    pub payment_address: Addr,
    pub share: Uint128,
}

pub const COLLECTION_INFO: Item<CollectionInfo> = Item::new("collection_info");
pub const TOKEN_ROYALTIES: Map<String, TokenRoyalty> = Map::new("token_royalties");
pub const SOULBOUND_TOKENS: Map<String, bool> = Map::new("soulbound");
pub const CREATOR_TOKENS: Map<(&Addr, String), bool> = Map::new("creator_tokens");

src/contract.rs

use cosmwasm_std::{
    entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response,
    StdError, StdResult, Addr, Uint128, Coin, Event, attr,
};
use cw2::set_contract_version;
use cw721::Cw721ExecuteMsg as Cw721Execute;
use cw721_base::{
    ContractError as Cw721ContractError, Cw721Contract, ExecuteMsg as Cw721ExecuteMsg,
    InstantiateMsg as Cw721InstantiateMsg, QueryMsg as Cw721QueryMsg,
    MintMsg as Cw721MintMsg,
};
use crate::error::ContractError;
use crate::msg::{
    ExecuteMsg, InstantiateMsg, MintMsg, QueryMsg, CollectionInfo,
    MetadataExtension, RoyaltyInfo, RoyaltyInfoResponse, TokensByCreatorResponse,
};
use crate::state::{TOKEN_ROYALTIES, SOULBOUND_TOKENS, CREATOR_TOKENS, TokenRoyalty, COLLECTION_INFO};

pub type Extension = MetadataExtension;
type NftContract = Cw721Contract<Extension, cosmwasm_std::Empty, cosmwasm_std::Empty, cosmwasm_std::Empty>;

const CONTRACT_NAME: &str = "msg-nft";
const CONTRACT_VERSION: &str = "1.0.0";

#[entry_point]
pub fn instantiate(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: InstantiateMsg,
) -> StdResult<Response> {
    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;

    let cw721_instantiate = Cw721InstantiateMsg {
        name: msg.name.clone(),
        symbol: msg.symbol.clone(),
        minter: msg.minter.clone(),
    };

    let cw721 = NftContract::default();
    cw721.instantiate(deps.branch(), env.clone(), info.clone(), cw721_instantiate)?;

    COLLECTION_INFO.save(deps.storage, &msg.collection_info)?;

    Ok(Response::new()
        .add_attribute("action", "instantiate")
        .add_attribute("contract_name", msg.name)
        .add_attribute("symbol", msg.symbol))
}

#[entry_point]
pub fn execute(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> Result<Response, ContractError> {
    match msg {
        ExecuteMsg::Mint(mint_msg) => execute_mint(deps, env, info, mint_msg),
        ExecuteMsg::Burn { token_id } => execute_burn(deps, env, info, token_id),
        ExecuteMsg::TransferNft { recipient, token_id } => {
            execute_transfer(deps, env, info, recipient, token_id)
        }
        ExecuteMsg::LockToken { token_id } => execute_lock_token(deps, env, info, token_id),
        ExecuteMsg::UpdateRoyalty { token_id, payment_address, share } => {
            execute_update_royalty(deps, env, info, token_id, payment_address, share)
        }
        _ => {
            let cw721 = NftContract::default();
            let cw721_msg: Cw721ExecuteMsg = match msg {
                ExecuteMsg::SendNft { contract, token_id, msg } => {
                    Cw721ExecuteMsg::SendNft { contract, token_id, msg }
                }
                ExecuteMsg::Approve { spender, token_id } => {
                    Cw721ExecuteMsg::Approve { spender, token_id }
                }
                ExecuteMsg::ApproveAll { operator } => {
                    Cw721ExecuteMsg::ApproveAll { operator }
                }
                ExecuteMsg::Revoke { spender, token_id } => {
                    Cw721ExecuteMsg::Revoke { spender, token_id }
                }
                ExecuteMsg::RevokeAll {} => Cw721ExecuteMsg::RevokeAll {},
                _ => return Err(ContractError::UnsupportedMessage {}),
            };
            cw721
                .execute(deps, env, info, cw721_msg)
                .map_err(|e| e.into())
        }
    }
}

pub fn execute_mint(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: MintMsg,
) -> Result<Response, ContractError> {
    let cw721 = NftContract::default();
    let minter = cw721.minter(deps.storage)?;

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

    let owner = deps.api.addr_validate(&msg.owner)?;
    let token_id = msg.token_id.clone();

    let cw721_mint = Cw721MintMsg::<Extension> {
        token_id: msg.token_id.clone(),
        owner: msg.owner.clone(),
        token_uri: msg.token_uri.clone(),
        extension: MetadataExtension {
            is_soulbound: msg.extension.is_soulbound,
            ..msg.extension.clone()
        },
    };

    cw721.mint(deps.branch(), info.clone(), &cw721_mint)?;

    if let Some(ref royalty) = msg.extension.royalty_info {
        let payment_addr = deps.api.addr_validate(&royalty.payment_address)?;
        let royalty_data = TokenRoyalty {
            token_id: token_id.clone(),
            payment_address: payment_addr,
            share: royalty.share,
        };
        TOKEN_ROYALTIES.save(deps.storage, &token_id, &royalty_data)?;
    }

    if let Some(true) = msg.extension.is_soulbound {
        SOULBOUND_TOKENS.save(deps.storage, &token_id, &true)?;
    }

    CREATOR_TOKENS.save(deps.storage, (&owner, &token_id), &true)?;

    let event = Event::new("msg-nft-mint")
        .add_attribute("token_id", &token_id)
        .add_attribute("owner", &msg.owner)
        .add_attribute("minter", info.sender.to_string())
        .add_attribute("collection", cw721.contract_info(deps.storage)?.name);

    Ok(Response::new()
        .add_event(event)
        .add_attribute("action", "mint")
        .add_attribute("token_id", token_id))
}

pub fn execute_burn(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
) -> Result<Response, ContractError> {
    let cw721 = NftContract::default();
    let owner = cw721.owner_of(deps.storage, &env, &token_id, true)?;
    let operator = info.sender;

    if operator != owner && !cw721.is_operator(deps.storage, &owner, &operator)? {
        return Err(ContractError::Unauthorized {});
    }

    cw721.burn(deps.storage, &env, &token_id)?;

    TOKEN_ROYALTIES.remove(deps.storage, &token_id);
    SOULBOUND_TOKENS.remove(deps.storage, &token_id);
    CREATOR_TOKENS.remove(deps.storage, (&owner, &token_id));

    let event = Event::new("msg-nft-burn")
        .add_attribute("token_id", &token_id)
        .add_attribute("burner", operator.to_string());

    Ok(Response::new()
        .add_event(event)
        .add_attribute("action", "burn")
        .add_attribute("token_id", token_id))
}

pub fn execute_transfer(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    recipient: String,
    token_id: String,
) -> Result<Response, ContractError> {
    let is_soulbound = SOULBOUND_TOKENS.load(deps.storage, &token_id).unwrap_or(false);
    if is_soulbound {
        return Err(ContractError::SoulboundToken {});
    }

    let cw721 = NftContract::default();
    let recipient_addr = deps.api.addr_validate(&recipient)?;

    cw721
        .transfer_nft(deps, &env, &info, &recipient_addr, &token_id)
        .map_err(|e| ContractError::from(e))?;

    let event = Event::new("msg-nft-transfer")
        .add_attribute("token_id", &token_id)
        .add_attribute("from", info.sender.to_string())
        .add_attribute("to", recipient);

    Ok(Response::new()
        .add_event(event)
        .add_attribute("action", "transfer")
        .add_attribute("token_id", token_id))
}

pub fn execute_lock_token(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
) -> Result<Response, ContractError> {
    let cw721 = NftContract::default();
    let owner = cw721.owner_of(deps.storage, &env, &token_id, true)?;

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

    SOULBOUND_TOKENS.save(deps.storage, &token_id, &true)?;

    let event = Event::new("msg-nft-lock")
        .add_attribute("token_id", &token_id)
        .add_attribute("owner", owner.to_string());

    Ok(Response::new()
        .add_event(event)
        .add_attribute("action", "lock")
        .add_attribute("token_id", token_id))
}

pub fn execute_update_royalty(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    payment_address: String,
    share: Uint128,
) -> Result<Response, ContractError> {
    let cw721 = NftContract::default();
    let owner = cw721.owner_of(deps.storage, &env, &token_id, true)?;

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

    let payment_addr = deps.api.addr_validate(&payment_address)?;
    let royalty_data = TokenRoyalty {
        token_id: token_id.clone(),
        payment_address: payment_addr,
        share,
    };
    TOKEN_ROYALTIES.save(deps.storage, &token_id, &royalty_data)?;

    Ok(Response::new()
        .add_attribute("action", "update_royalty")
        .add_attribute("token_id", token_id)
        .add_attribute("payment_address", payment_address)
        .add_attribute("share", share.to_string()))
}

#[entry_point]
pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
    match msg {
        QueryMsg::RoyaltyInfo { token_id, sale_price } => {
            to_json_binary(&query_royalty_info(deps, token_id, sale_price)?)
        }
        QueryMsg::IsSoulbound { token_id } => {
            let is_soulbound = SOULBOUND_TOKENS.load(deps.storage, &token_id).unwrap_or(false);
            to_json_binary(&is_soulbound)
        }
        QueryMsg::TokensByCreator { creator, start_after, limit } => {
            to_json_binary(&query_tokens_by_creator(deps, creator, start_after, limit)?)
        }
        QueryMsg::CollectionInfo {} => {
            let info = COLLECTION_INFO.load(deps.storage)?;
            to_json_binary(&info)
        }
        _ => {
            let cw721 = NftContract::default();
            match msg {
                QueryMsg::OwnerOf { token_id, include_expired } => {
                    cw721.query(deps, env, Cw721QueryMsg::OwnerOf { token_id, include_expired })
                }
                QueryMsg::Approval { token_id, spender, include_expired } => {
                    cw721.query(deps, env, Cw721QueryMsg::Approval { token_id, spender, include_expired })
                }
                QueryMsg::Approvals { token_id, include_expired } => {
                    cw721.query(deps, env, Cw721QueryMsg::Approvals { token_id, include_expired })
                }
                QueryMsg::AllOperators { owner, include_expired, start_after, limit } => {
                    cw721.query(deps, env, Cw721QueryMsg::AllOperators { owner, include_expired, start_after, limit })
                }
                QueryMsg::NumTokens {} => cw721.query(deps, env, Cw721QueryMsg::NumTokens {}),
                QueryMsg::AllTokens { start_after, limit } => {
                    cw721.query(deps, env, Cw721QueryMsg::AllTokens { start_after, limit })
                }
                QueryMsg::Minter {} => cw721.query(deps, env, Cw721QueryMsg::Minter {}),
                QueryMsg::Tokens { owner, start_after, limit } => {
                    cw721.query(deps, env, Cw721QueryMsg::Tokens { owner, start_after, limit })
                }
                QueryMsg::NftInfo { token_id } => {
                    cw721.query(deps, env, Cw721QueryMsg::NftInfo { token_id })
                }
                QueryMsg::AllNftInfo { token_id } => {
                    cw721.query(deps, env, Cw721QueryMsg::AllNftInfo { token_id })
                }
                QueryMsg::ContractInfo {} => cw721.query(deps, env, Cw721QueryMsg::ContractInfo {}),
                _ => Err(StdError::generic_err("Unknown query variant")),
            }
        }
    }
}

fn query_royalty_info(deps: Deps, token_id: String, sale_price: Uint128) -> StdResult<RoyaltyInfoResponse> {
    let royalty = TOKEN_ROYALTIES.load(deps.storage, &token_id)?;
    Ok(RoyaltyInfoResponse {
        address: royalty.payment_address.to_string(),
        share: royalty.share,
    })
}

fn query_tokens_by_creator(
    deps: Deps,
    creator: String,
    start_after: Option<String>,
    limit: Option<u32>,
) -> StdResult<TokensByCreatorResponse> {
    let creator_addr = deps.api.addr_validate(&creator)?;
    let limit = limit.unwrap_or(30).min(100) as usize;

    let tokens: Vec<String> = CREATOR_TOKENS
        .prefix(&creator_addr)
        .range(deps.storage, None, None, cosmwasm_std::Order::Ascending)
        .take(limit)
        .map(|item| item.map(|(k, _)| String::from_utf8(k).unwrap_or_default()))
        .collect::<StdResult<Vec<_>>>()?;

    Ok(TokensByCreatorResponse { tokens })
}

src/error.rs

use cosmwasm_std::StdError;
use cw721_base::ContractError as Cw721ContractError;
use thiserror::Error;

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

    #[error("{0}")]
    Cw721(#[from] Cw721ContractError),

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

    #[error("Token is soulbound and cannot be transferred")]
    SoulboundToken {},

    #[error("Royalty share exceeds maximum (10000 bps)")]
    RoyaltyTooHigh {},

    #[error("Token does not exist")]
    TokenNotFound {},

    #[error("Unsupported message")]
    UnsupportedMessage {},
}

src/royalties.rs

use cosmwasm_std::{Addr, Uint128, Deps, StdResult, StdError};
use crate::state::TOKEN_ROYALTIES;

pub fn get_royalty_payout(
    deps: Deps,
    token_id: &str,
    sale_price: Uint128,
) -> StdResult<(Addr, Uint128)> {
    let royalty = TOKEN_ROYALTIES
        .load(deps.storage, token_id)
        .map_err(|_| StdError::generic_err(format!("No royalty for token {}", token_id)))?;

    let royalty_amount = sale_price.multiply_ratio(royalty.share, Uint128::from(10000u128));

    Ok((royalty.payment_address, royalty_amount))
}

pub fn validate_royalty(share: Uint128) -> Result<(), crate::error::ContractError> {
    if share > Uint128::from(10000u128) {
        return Err(crate::error::ContractError::RoyaltyTooHigh {});
    }
    Ok(())
}

src/helpers.rs

use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, StdResult, WasmMsg};
use crate::msg::{ExecuteMsg, MintMsg, MetadataExtension};

pub fn mint_nft_msg(
    contract: Addr,
    token_id: String,
    owner: String,
    token_uri: Option<String>,
    extension: MetadataExtension,
) -> StdResult<CosmosMsg> {
    let msg = ExecuteMsg::Mint(MintMsg {
        token_id,
        owner,
        token_uri,
        extension,
    });
    Ok(CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract.to_string(),
        msg: to_json_binary(&msg)?,
        funds: vec![],
    }))
}

pub fn burn_nft_msg(
    contract: Addr,
    token_id: String,
) -> StdResult<CosmosMsg> {
    Ok(CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract.to_string(),
        msg: to_json_binary(&ExecuteMsg::Burn { token_id })?,
        funds: vec![],
    }))
}

pub fn lock_nft_msg(
    contract: Addr,
    token_id: String,
) -> StdResult<CosmosMsg> {
    Ok(CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract.to_string(),
        msg: to_json_binary(&ExecuteMsg::LockToken { token_id })?,
        funds: vec![],
    }))
}

src/tests.rs

#[cfg(test)]
mod tests {
    use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
    use cosmwasm_std::{from_json, Uint128};
    use crate::contract::{instantiate, execute, query};
    use crate::msg::{
        InstantiateMsg, ExecuteMsg, QueryMsg, MintMsg, CollectionInfo,
        MetadataExtension, RoyaltyInfo, Trait,
    };

    fn get_collection_info() -> CollectionInfo {
        CollectionInfo {
            creator: "creator".to_string(),
            description: "Test collection".to_string(),
            image: "https://example.com/collection.png".to_string(),
            external_link: None,
            royalty_info: Some(RoyaltyInfo {
                payment_address: "creator".to_string(),
                share: Uint128::from(500u128),
            }),
        }
    }

    fn get_metadata() -> MetadataExtension {
        MetadataExtension {
            name: Some("Test NFT".to_string()),
            description: Some("A test NFT".to_string()),
            image: Some("https://example.com/nft.png".to_string()),
            image_data: None,
            external_url: None,
            animation_url: None,
            youtube_url: None,
            attributes: Some(vec![Trait {
                display_type: None,
                trait_type: "Rarity".to_string(),
                value: "Legendary".to_string(),
                max_value: None,
            }]),
            background_color: None,
            royalty_info: Some(RoyaltyInfo {
                payment_address: "creator".to_string(),
                share: Uint128::from(500u128),
            }),
            is_soulbound: Some(false),
        }
    }

    #[test]
    fn proper_instantiation() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let info = mock_info("minter", &[]);

        let msg = InstantiateMsg {
            name: "TestNFT".to_string(),
            symbol: "TNFT".to_string(),
            minter: "minter".to_string(),
            collection_info: get_collection_info(),
        };

        let res = instantiate(deps.as_mut(), env, info, msg).unwrap();
        assert_eq!(res.attributes[1].value, "TestNFT");
    }

    #[test]
    fn mint_nft() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let info = mock_info("minter", &[]);

        let init_msg = InstantiateMsg {
            name: "TestNFT".to_string(),
            symbol: "TNFT".to_string(),
            minter: "minter".to_string(),
            collection_info: get_collection_info(),
        };
        instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap();

        let mint_msg = ExecuteMsg::Mint(MintMsg {
            token_id: "1".to_string(),
            owner: "owner".to_string(),
            token_uri: Some("https://example.com/metadata/1.json".to_string()),
            extension: get_metadata(),
        });

        let res = execute(deps.as_mut(), env.clone(), info, mint_msg).unwrap();
        assert_eq!(res.attributes[1].value, "1");
    }

    #[test]
    fn soulbound_token_cannot_transfer() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let info = mock_info("minter", &[]);

        let init_msg = InstantiateMsg {
            name: "TestNFT".to_string(),
            symbol: "TNFT".to_string(),
            minter: "minter".to_string(),
            collection_info: get_collection_info(),
        };
        instantiate(deps.as_mut(), env.clone(), info.clone(), init_msg).unwrap();

        let mut metadata = get_metadata();
        metadata.is_soulbound = Some(true);

        let mint_msg = ExecuteMsg::Mint(MintMsg {
            token_id: "1".to_string(),
            owner: "owner".to_string(),
            token_uri: None,
            extension: metadata,
        });
        execute(deps.as_mut(), env.clone(), info.clone(), mint_msg).unwrap();

        let transfer_msg = ExecuteMsg::TransferNft {
            recipient: "new_owner".to_string(),
            token_id: "1".to_string(),
        };
        let info_owner = mock_info("owner", &[]);
        let err = execute(deps.as_mut(), env.clone(), info_owner, transfer_msg).unwrap_err();
        assert_eq!(err.to_string(), "Token is soulbound and cannot be transferred");
    }
}

Schema 生成(examples/schema.rs)

use cosmwasm_schema::write_api;
use msg_nft::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};

fn main() {
    write_api! {
        instantiate: InstantiateMsg,
        execute: ExecuteMsg,
        query: QueryMsg,
    }
}

3. Part 2: 市场智能合约

项目结构

marketplace-contract/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── msg.rs
│   ├── state.rs
│   ├── contract.rs
│   ├── error.rs
│   ├── escrow.rs
│   ├── royalties.rs
│   └── helpers.rs
└── examples/
    └── schema.rs

Cargo.toml

[package]
name = "msg-marketplace"
version = "1.0.0"
edition = "2021"
description = "MSG Chain NFT 市场合约"

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

[features]
default = ["library"]
library = []

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

[dev-dependencies]
cw-multi-test = "0.18"

[profile.release]
opt-level = 3
debug = false
rpath = false
lto = true
overflow-checks = true
codegen-units = 1

src/lib.rs

pub mod msg;
pub mod state;
pub mod contract;
pub mod error;
pub mod escrow;
pub mod royalties;
pub mod helpers;

#[cfg(test)]
mod tests;

src/msg.rs

use cosmwasm_schema::cw_serde;
use cosmwasm_std::{Addr, Coin, Uint128};

#[cw_serde]
pub struct InstantiateMsg {
    pub fee_percentage: u64,
    pub fee_recipient: String,
    pub accepted_denoms: Vec<String>,
}

#[cw_serde]
pub enum ExecuteMsg {
    List {
        token_id: String,
        contract_addr: String,
        price: Coin,
        duration: u64,
    },
    Unlist {
        token_id: String,
        contract_addr: String,
    },
    Buy {
        token_id: String,
        contract_addr: String,
    },
    MakeOffer {
        token_id: String,
        contract_addr: String,
        offer: Coin,
        duration: u64,
    },
    AcceptOffer {
        token_id: String,
        contract_addr: String,
        offerer: String,
    },
    CancelOffer {
        token_id: String,
        contract_addr: String,
    },
    UpdateRoyalty {
        recipient: String,
        fee_bps: u16,
    },
    UpdateConfig {
        fee_percentage: Option<u64>,
        fee_recipient: Option<String>,
        accepted_denoms: Option<Vec<String>>,
    },
}

#[cw_serde]
pub enum QueryMsg {
    Listing {
        token_id: String,
        contract_addr: String,
    },
    Listings {
        start_after: Option<String>,
        limit: Option<u32>,
        filter: Option<ListingFilter>,
    },
    Offers {
        token_id: String,
        contract_addr: String,
    },
    Royalties {
        token_id: String,
        contract_addr: String,
    },
    Config {},
    ListingsBySeller {
        seller: String,
        start_after: Option<String>,
        limit: Option<u32>,
    },
}

#[cw_serde]
pub enum ListingFilter {
    Active,
    Sold,
    Cancelled,
    Expired,
    ByCollection { contract_addr: String },
    BySeller { seller: String },
}

#[cw_serde]
pub struct ListingResponse {
    pub token_id: String,
    pub contract_addr: String,
    pub seller: String,
    pub price: Coin,
    pub duration: u64,
    pub created_at: u64,
    pub status: ListingStatus,
}

#[cw_serde]
pub enum ListingStatus {
    Active,
    Sold,
    Cancelled,
    Expired,
}

#[cw_serde]
pub struct OfferResponse {
    pub token_id: String,
    pub contract_addr: String,
    pub offerer: String,
    pub offer: Coin,
    pub expiration: u64,
    pub created_at: u64,
}

#[cw_serde]
pub struct ConfigResponse {
    pub fee_percentage: u64,
    pub fee_recipient: String,
    pub accepted_denoms: Vec<String>,
    pub owner: String,
}

#[cw_serde]
pub struct RoyaltyInfoResponse {
    pub recipient: String,
    pub fee_bps: u16,
}

#[cw_serde]
pub struct ListingsResponse {
    pub listings: Vec<ListingResponse>,
}

#[cw_serde]
pub struct OffersResponse {
    pub offers: Vec<OfferResponse>,
}

src/state.rs

use cw_storage_plus::{Item, Map, IndexedMap, MultiIndex};
use cosmwasm_std::{Addr, Coin, Uint128};
use serde::{Deserialize, Serialize};
use crate::msg::ListingStatus;

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Listing {
    pub token_id: String,
    pub contract_addr: Addr,
    pub seller: Addr,
    pub price: Coin,
    pub duration: u64,
    pub created_at: u64,
    pub status: ListingStatus,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Offer {
    pub token_id: String,
    pub contract_addr: Addr,
    pub offerer: Addr,
    pub offer: Coin,
    pub expiration: u64,
    pub created_at: u64,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Config {
    pub fee_percentage: u64,
    pub fee_recipient: Addr,
    pub accepted_denoms: Vec<String>,
    pub owner: Addr,
}

#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct MarketplaceRoyalty {
    pub recipient: Addr,
    pub fee_bps: u16,
}

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

pub fn listing_key(token_id: &str, contract_addr: &Addr) -> String {
    format!("{}:{}", contract_addr, token_id)
}

pub fn listings() -> IndexedMap<'static, String, Listing, MultiIndex<'static, Addr, Listing, String>> {
    let indexes = MultiIndex::new(
        |_pk: &[u8], listing: &Listing| listing.seller.clone(),
        "listings",
        "listings__seller",
    );
    IndexedMap::new("listings", indexes)
}

pub fn all_listings() -> Map<String, Listing> {
    Map::new("listings")
}

pub fn offers() -> Map<String, Vec<Offer>> {
    Map::new("offers")
}

src/contract.rs

use cosmwasm_std::{
    entry_point, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response,
    StdResult, Addr, Coin, Uint128, BankMsg, CosmosMsg, WasmMsg, Empty, Event, attr,
    Order as CwOrder,
};
use cw2::set_contract_version;
use cw721::Cw721ExecuteMsg;
use crate::error::ContractError;
use crate::msg::{
    ExecuteMsg, InstantiateMsg, QueryMsg, ListingResponse, ListingStatus,
    OfferResponse, ConfigResponse, ListingFilter, ListingsResponse, OffersResponse,
    RoyaltyInfoResponse,
};
use crate::state::{
    self, CONFIG, Config, Listing, Offer, MarketplaceRoyalty,
    MARKETPLACE_ROYALTY, all_listings, offers,
};

const CONTRACT_NAME: &str = "msg-marketplace";
const CONTRACT_VERSION: &str = "1.0.0";

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

    let fee_recipient = deps.api.addr_validate(&msg.fee_recipient)?;

    let config = Config {
        fee_percentage: msg.fee_percentage,
        fee_recipient,
        accepted_denoms: msg.accepted_denoms,
        owner: info.sender,
    };
    CONFIG.save(deps.storage, &config)?;

    let default_royalty = MarketplaceRoyalty {
        recipient: Addr::unchecked(""),
        fee_bps: 0,
    };
    MARKETPLACE_ROYALTY.save(deps.storage, &default_royalty)?;

    Ok(Response::new()
        .add_attribute("action", "instantiate")
        .add_attribute("fee_percentage", msg.fee_percentage.to_string())
        .add_attribute("fee_recipient", msg.fee_recipient))
}

#[entry_point]
pub fn execute(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> Result<Response, ContractError> {
    match msg {
        ExecuteMsg::List { token_id, contract_addr, price, duration } => {
            execute_list(deps, env, info, token_id, contract_addr, price, duration)
        }
        ExecuteMsg::Unlist { token_id, contract_addr } => {
            execute_unlist(deps, env, info, token_id, contract_addr)
        }
        ExecuteMsg::Buy { token_id, contract_addr } => {
            execute_buy(deps, env, info, token_id, contract_addr)
        }
        ExecuteMsg::MakeOffer { token_id, contract_addr, offer, duration } => {
            execute_make_offer(deps, env, info, token_id, contract_addr, offer, duration)
        }
        ExecuteMsg::AcceptOffer { token_id, contract_addr, offerer } => {
            execute_accept_offer(deps, env, info, token_id, contract_addr, offerer)
        }
        ExecuteMsg::CancelOffer { token_id, contract_addr } => {
            execute_cancel_offer(deps, env, info, token_id, contract_addr)
        }
        ExecuteMsg::UpdateRoyalty { recipient, fee_bps } => {
            execute_update_royalty(deps, env, info, recipient, fee_bps)
        }
        ExecuteMsg::UpdateConfig { fee_percentage, fee_recipient, accepted_denoms } => {
            execute_update_config(deps, env, info, fee_percentage, fee_recipient, accepted_denoms)
        }
    }
}

pub fn execute_list(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    contract_addr: String,
    price: Coin,
    duration: u64,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;

    if !config.accepted_denoms.contains(&price.denom) {
        return Err(ContractError::UnsupportedDenom {});
    }

    if duration == 0 || duration > 31536000 {
        return Err(ContractError::InvalidDuration {});
    }

    let contract = deps.api.addr_validate(&contract_addr)?;
    let key = state::listing_key(&token_id, &contract);

    let existing = all_listings().may_load(deps.storage, key.clone())?;
    if let Some(ref listing) = existing {
        if listing.status == ListingStatus::Active {
            return Err(ContractError::AlreadyListed {});
        }
    }

    let listing = Listing {
        token_id: token_id.clone(),
        contract_addr: contract.clone(),
        seller: info.sender.clone(),
        price: price.clone(),
        duration,
        created_at: env.block.height,
        status: ListingStatus::Active,
    };
    all_listings().save(deps.storage, key, &listing)?;

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

    let event = Event::new("msg-marketplace-list")
        .add_attribute("token_id", &token_id)
        .add_attribute("contract_addr", &contract_addr)
        .add_attribute("seller", info.sender.to_string())
        .add_attribute("price", price.to_string())
        .add_attribute("duration", duration.to_string());

    Ok(Response::new()
        .add_message(transfer_msg)
        .add_event(event)
        .add_attribute("action", "list")
        .add_attribute("token_id", token_id))
}

pub fn execute_unlist(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    contract_addr: String,
) -> Result<Response, ContractError> {
    let contract = deps.api.addr_validate(&contract_addr)?;
    let key = state::listing_key(&token_id, &contract);
    let mut listing = all_listings().load(deps.storage, key.clone())?;

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

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

    listing.status = ListingStatus::Cancelled;
    all_listings().save(deps.storage, key, &listing)?;

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

    let event = Event::new("msg-marketplace-unlist")
        .add_attribute("token_id", &token_id)
        .add_attribute("contract_addr", &contract_addr)
        .add_attribute("seller", info.sender.to_string());

    Ok(Response::new()
        .add_message(transfer_back)
        .add_event(event)
        .add_attribute("action", "unlist")
        .add_attribute("token_id", token_id))
}

pub fn execute_buy(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    contract_addr: String,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    let contract = deps.api.addr_validate(&contract_addr)?;
    let key = state::listing_key(&token_id, &contract);
    let mut listing = all_listings().load(deps.storage, key.clone())?;

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

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

    let payment = info
        .funds
        .iter()
        .find(|c| c.denom == listing.price.denom)
        .ok_or(ContractError::InsufficientPayment {})?;

    if payment.amount < listing.price.amount {
        return Err(ContractError::InsufficientPayment {});
    }

    let sale_price = listing.price.amount;
    let fee_amount = sale_price.multiply_ratio(config.fee_percentage, 10000u128);
    let seller_amount = sale_price.checked_sub(fee_amount)
        .map_err(|_| ContractError::ArithmeticError {})?;

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

    let mut messages: Vec<CosmosMsg> = vec![];

    messages.push(CosmosMsg::Bank(BankMsg::Send {
        to_address: listing.seller.to_string(),
        amount: vec![Coin {
            denom: listing.price.denom.clone(),
            amount: seller_amount,
        }],
    }));

    if fee_amount > Uint128::zero() {
        messages.push(CosmosMsg::Bank(BankMsg::Send {
            to_address: config.fee_recipient.to_string(),
            amount: vec![Coin {
                denom: listing.price.denom.clone(),
                amount: fee_amount,
            }],
        }));
    }

    messages.push(CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract_addr.clone(),
        msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
            recipient: info.sender.to_string(),
            token_id: token_id.clone(),
        })?,
        funds: vec![],
    }));

    let event = Event::new("msg-marketplace-buy")
        .add_attribute("token_id", &token_id)
        .add_attribute("contract_addr", &contract_addr)
        .add_attribute("buyer", info.sender.to_string())
        .add_attribute("seller", listing.seller.to_string())
        .add_attribute("price", listing.price.to_string())
        .add_attribute("fee", fee_amount.to_string());

    Ok(Response::new()
        .add_messages(messages)
        .add_event(event)
        .add_attribute("action", "buy")
        .add_attribute("token_id", token_id))
}

pub fn execute_make_offer(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    contract_addr: String,
    offer: Coin,
    duration: u64,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;

    if !config.accepted_denoms.contains(&offer.denom) {
        return Err(ContractError::UnsupportedDenom {});
    }

    if duration == 0 || duration > 31536000 {
        return Err(ContractError::InvalidDuration {});
    }

    let payment = info
        .funds
        .iter()
        .find(|c| c.denom == offer.denom)
        .ok_or(ContractError::InsufficientPayment {})?;

    if payment.amount < offer.amount {
        return Err(ContractError::InsufficientPayment {});
    }

    let contract = deps.api.addr_validate(&contract_addr)?;
    let key = state::listing_key(&token_id, &contract);

    let offer_entry = Offer {
        token_id: token_id.clone(),
        contract_addr: contract.clone(),
        offerer: info.sender.clone(),
        offer: offer.clone(),
        expiration: env.block.height + duration,
        created_at: env.block.height,
    };

    let mut existing_offers = offers().load(deps.storage, key.clone()).unwrap_or_default();
    existing_offers.push(offer_entry);
    offers().save(deps.storage, key, &existing_offers)?;

    let event = Event::new("msg-marketplace-offer")
        .add_attribute("token_id", &token_id)
        .add_attribute("contract_addr", &contract_addr)
        .add_attribute("offerer", info.sender.to_string())
        .add_attribute("offer", offer.to_string())
        .add_attribute("duration", duration.to_string());

    Ok(Response::new()
        .add_event(event)
        .add_attribute("action", "make_offer")
        .add_attribute("token_id", token_id))
}

pub fn execute_accept_offer(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    contract_addr: String,
    offerer: String,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    let contract = deps.api.addr_validate(&contract_addr)?;
    let key = state::listing_key(&token_id, &contract);
    let mut listing = all_listings().load(deps.storage, key.clone())?;

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

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

    let offerer_addr = deps.api.addr_validate(&offerer)?;
    let mut existing_offers = offers().load(deps.storage, key.clone())?;
    let offer_idx = existing_offers
        .iter()
        .position(|o| o.offerer == offerer_addr && o.expiration > env.block.height);

    match offer_idx {
        Some(idx) => {
            let accepted_offer = existing_offers.remove(idx);
            offers().save(deps.storage, key, &existing_offers)?;

            let sale_price = accepted_offer.offer.amount;
            let fee_amount = sale_price.multiply_ratio(config.fee_percentage, 10000u128);
            let seller_amount = sale_price.checked_sub(fee_amount)
                .map_err(|_| ContractError::ArithmeticError {})?;

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

            let mut messages: Vec<CosmosMsg> = vec![];

            messages.push(CosmosMsg::Bank(BankMsg::Send {
                to_address: listing.seller.to_string(),
                amount: vec![Coin {
                    denom: accepted_offer.offer.denom.clone(),
                    amount: seller_amount,
                }],
            }));

            if fee_amount > Uint128::zero() {
                messages.push(CosmosMsg::Bank(BankMsg::Send {
                    to_address: config.fee_recipient.to_string(),
                    amount: vec![Coin {
                        denom: accepted_offer.offer.denom.clone(),
                        amount: fee_amount,
                    }],
                }));
            }

            messages.push(CosmosMsg::Wasm(WasmMsg::Execute {
                contract_addr: contract_addr.clone(),
                msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
                    recipient: offerer.clone(),
                    token_id: token_id.clone(),
                })?,
                funds: vec![],
            }));

            let event = Event::new("msg-marketplace-accept-offer")
                .add_attribute("token_id", &token_id)
                .add_attribute("contract_addr", &contract_addr)
                .add_attribute("seller", info.sender.to_string())
                .add_attribute("buyer", offerer)
                .add_attribute("price", accepted_offer.offer.to_string())
                .add_attribute("fee", fee_amount.to_string());

            Ok(Response::new()
                .add_messages(messages)
                .add_event(event)
                .add_attribute("action", "accept_offer")
                .add_attribute("token_id", token_id))
        }
        None => Err(ContractError::OfferNotFound {}),
    }
}

pub fn execute_cancel_offer(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    token_id: String,
    contract_addr: String,
) -> Result<Response, ContractError> {
    let contract = deps.api.addr_validate(&contract_addr)?;
    let key = state::listing_key(&token_id, &contract);
    let mut existing_offers = offers().load(deps.storage, key.clone())?;

    let offer_idx = existing_offers.iter().position(|o| o.offerer == info.sender);
    match offer_idx {
        Some(idx) => {
            let cancelled_offer = existing_offers.remove(idx);
            offers().save(deps.storage, key, &existing_offers)?;

            let refund = BankMsg::Send {
                to_address: info.sender.to_string(),
                amount: vec![cancelled_offer.offer],
            };

            let event = Event::new("msg-marketplace-cancel-offer")
                .add_attribute("token_id", &token_id)
                .add_attribute("contract_addr", &contract_addr)
                .add_attribute("offerer", info.sender.to_string());

            Ok(Response::new()
                .add_message(refund)
                .add_event(event)
                .add_attribute("action", "cancel_offer")
                .add_attribute("token_id", token_id))
        }
        None => Err(ContractError::OfferNotFound {}),
    }
}

pub fn execute_update_royalty(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    recipient: String,
    fee_bps: u16,
) -> Result<Response, ContractError> {
    let config = CONFIG.load(deps.storage)?;
    if info.sender != config.owner {
        return Err(ContractError::Unauthorized {});
    }

    if fee_bps > 10000 {
        return Err(ContractError::InvalidRoyalty {});
    }

    let recipient_addr = deps.api.addr_validate(&recipient)?;
    let royalty = MarketplaceRoyalty {
        recipient: recipient_addr,
        fee_bps,
    };
    MARKETPLACE_ROYALTY.save(deps.storage, &royalty)?;

    Ok(Response::new()
        .add_attribute("action", "update_royalty")
        .add_attribute("recipient", recipient)
        .add_attribute("fee_bps", fee_bps.to_string()))
}

pub fn execute_update_config(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    fee_percentage: Option<u64>,
    fee_recipient: Option<String>,
    accepted_denoms: Option<Vec<String>>,
) -> Result<Response, ContractError> {
    let mut config = CONFIG.load(deps.storage)?;
    if info.sender != config.owner {
        return Err(ContractError::Unauthorized {});
    }

    if let Some(fee) = fee_percentage {
        if fee > 10000 {
            return Err(ContractError::InvalidFeePercentage {});
        }
        config.fee_percentage = fee;
    }

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

    if let Some(denoms) = accepted_denoms {
        config.accepted_denoms = denoms;
    }

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

    Ok(Response::new()
        .add_attribute("action", "update_config"))
}

#[entry_point]
pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
    match msg {
        QueryMsg::Listing { token_id, contract_addr } => {
            to_json_binary(&query_listing(deps, token_id, contract_addr)?)
        }
        QueryMsg::Listings { start_after, limit, filter } => {
            to_json_binary(&query_listings(deps, start_after, limit, filter)?)
        }
        QueryMsg::Offers { token_id, contract_addr } => {
            to_json_binary(&query_offers(deps, token_id, contract_addr)?)
        }
        QueryMsg::Royalties { token_id, contract_addr } => {
            to_json_binary(&query_royalties(deps, token_id, contract_addr)?)
        }
        QueryMsg::Config {} => to_json_binary(&query_config(deps)?),
        QueryMsg::ListingsBySeller { seller, start_after, limit } => {
            to_json_binary(&query_listings_by_seller(deps, seller, start_after, limit)?)
        }
    }
}

fn query_listing(deps: Deps, token_id: String, contract_addr: String) -> StdResult<ListingResponse> {
    let contract = deps.api.addr_validate(&contract_addr)?;
    let key = state::listing_key(&token_id, &contract);
    let listing = all_listings().load(deps.storage, key)?;
    Ok(ListingResponse {
        token_id: listing.token_id,
        contract_addr: listing.contract_addr.to_string(),
        seller: listing.seller.to_string(),
        price: listing.price,
        duration: listing.duration,
        created_at: listing.created_at,
        status: listing.status,
    })
}

fn query_listings(
    deps: Deps,
    start_after: Option<String>,
    limit: Option<u32>,
    filter: Option<ListingFilter>,
) -> StdResult<ListingsResponse> {
    let limit = limit.unwrap_or(30).min(100) as usize;
    let list_map = all_listings();

    let iter: Box<dyn Iterator<Item = StdResult<(String, Listing)>>> = match filter {
        Some(ListingFilter::Active) => {
            let items: Vec<_> = list_map
                .range(deps.storage, None, None, CwOrder::Ascending)
                .filter(|item| {
                    item.as_ref()
                        .map(|(_, l)| l.status == ListingStatus::Active)
                        .unwrap_or(false)
                })
                .take(limit)
                .collect();
            Box::new(items.into_iter())
        }
        Some(ListingFilter::Sold) => {
            let items: Vec<_> = list_map
                .range(deps.storage, None, None, CwOrder::Ascending)
                .filter(|item| {
                    item.as_ref()
                        .map(|(_, l)| l.status == ListingStatus::Sold)
                        .unwrap_or(false)
                })
                .take(limit)
                .collect();
            Box::new(items.into_iter())
        }
        Some(ListingFilter::ByCollection { contract_addr }) => {
            let addr = deps.api.addr_validate(&contract_addr)?;
            let items: Vec<_> = list_map
                .range(deps.storage, None, None, CwOrder::Ascending)
                .filter(|item| {
                    item.as_ref()
                        .map(|(_, l)| l.contract_addr == addr)
                        .unwrap_or(false)
                })
                .take(limit)
                .collect();
            Box::new(items.into_iter())
        }
        Some(ListingFilter::BySeller { seller }) => {
            let addr = deps.api.addr_validate(&seller)?;
            let items: Vec<_> = list_map
                .range(deps.storage, None, None, CwOrder::Ascending)
                .filter(|item| {
                    item.as_ref()
                        .map(|(_, l)| l.seller == addr)
                        .unwrap_or(false)
                })
                .take(limit)
                .collect();
            Box::new(items.into_iter())
        }
        _ => Box::new(list_map.range(deps.storage, None, None, CwOrder::Ascending).take(limit)),
    };

    let listings: Vec<ListingResponse> = iter
        .map(|item| {
            item.map(|(_, l)| ListingResponse {
                token_id: l.token_id,
                contract_addr: l.contract_addr.to_string(),
                seller: l.seller.to_string(),
                price: l.price,
                duration: l.duration,
                created_at: l.created_at,
                status: l.status,
            })
        })
        .collect::<StdResult<Vec<_>>>()?;

    Ok(ListingsResponse { listings })
}

fn query_listings_by_seller(
    deps: Deps,
    seller: String,
    start_after: Option<String>,
    limit: Option<u32>,
) -> StdResult<ListingsResponse> {
    let seller_addr = deps.api.addr_validate(&seller)?;
    let limit = limit.unwrap_or(30).min(100) as usize;
    let list_map = all_listings();

    let iter = list_map
        .range(deps.storage, None, None, CwOrder::Ascending)
        .filter(|item| {
            item.as_ref()
                .map(|(_, l)| l.seller == seller_addr)
                .unwrap_or(false)
        })
        .take(limit);

    let listings: Vec<ListingResponse> = iter
        .map(|item| {
            item.map(|(_, l)| ListingResponse {
                token_id: l.token_id,
                contract_addr: l.contract_addr.to_string(),
                seller: l.seller.to_string(),
                price: l.price,
                duration: l.duration,
                created_at: l.created_at,
                status: l.status,
            })
        })
        .collect::<StdResult<Vec<_>>>()?;

    Ok(ListingsResponse { listings })
}

fn query_offers(deps: Deps, token_id: String, contract_addr: String) -> StdResult<OffersResponse> {
    let contract = deps.api.addr_validate(&contract_addr)?;
    let key = state::listing_key(&token_id, &contract);
    let existing_offers = offers().load(deps.storage, key).unwrap_or_default();

    let offers: Vec<OfferResponse> = existing_offers
        .into_iter()
        .map(|o| OfferResponse {
            token_id: o.token_id,
            contract_addr: o.contract_addr.to_string(),
            offerer: o.offerer.to_string(),
            offer: o.offer,
            expiration: o.expiration,
            created_at: o.created_at,
        })
        .collect();

    Ok(OffersResponse { offers })
}

fn query_royalties(deps: Deps, token_id: String, contract_addr: String) -> StdResult<RoyaltyInfoResponse> {
    let royalty = MARKETPLACE_ROYALTY.load(deps.storage)?;
    Ok(RoyaltyInfoResponse {
        recipient: royalty.recipient.to_string(),
        fee_bps: royalty.fee_bps,
    })
}

fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
    let config = CONFIG.load(deps.storage)?;
    Ok(ConfigResponse {
        fee_percentage: config.fee_percentage,
        fee_recipient: config.fee_recipient.to_string(),
        accepted_denoms: config.accepted_denoms,
        owner: config.owner.to_string(),
    })
}

src/error.rs

use cosmwasm_std::StdError;
use thiserror::Error;

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

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

    #[error("Already listed")]
    AlreadyListed {},

    #[error("Not an active listing")]
    NotActiveListing {},

    #[error("Cannot buy your own NFT")]
    CannotBuyOwnNFT {},

    #[error("Insufficient payment")]
    InsufficientPayment {},

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

    #[error("Unsupported denomination")]
    UnsupportedDenom {},

    #[error("Invalid duration")]
    InvalidDuration {},

    #[error("Invalid royalty")]
    InvalidRoyalty {},

    #[error("Invalid fee percentage")]
    InvalidFeePercentage {},

    #[error("Arithmetic error")]
    ArithmeticError {},

    #[error("NFT not in escrow")]
    NftNotInEscrow {},

    #[error("Escrow error: {0}")]
    EscrowError(String),
}

src/escrow.rs

use cosmwasm_std::{
    Addr, BankMsg, Coin, CosmosMsg, DepsMut, Env, MessageInfo, Response,
    WasmMsg, to_json_binary,
};
use cw721::Cw721ExecuteMsg;
use crate::error::ContractError;
use crate::state;

pub fn deposit_nft(
    deps: DepsMut,
    contract_addr: &Addr,
    token_id: &str,
) -> Result<Response, ContractError> {
    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract_addr.to_string(),
        msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
            recipient: deps.api.addr_humanize(&deps.api.addr_canonicalize(contract_addr.as_str())?)?,
            token_id: token_id.to_string(),
        })?,
        funds: vec![],
    });

    Ok(Response::new().add_message(transfer_msg))
}

pub fn withdraw_nft(
    deps: DepsMut,
    contract_addr: &Addr,
    token_id: &str,
    recipient: &Addr,
) -> Result<Response, ContractError> {
    let transfer_msg = CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract_addr.to_string(),
        msg: to_json_binary(&Cw721ExecuteMsg::TransferNft {
            recipient: recipient.to_string(),
            token_id: token_id.to_string(),
        })?,
        funds: vec![],
    });

    Ok(Response::new().add_message(transfer_msg))
}

src/helpers.rs

use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, StdResult, WasmMsg, Coin};
use crate::msg::ExecuteMsg;

pub fn list_nft_msg(
    contract: Addr,
    token_id: String,
    nft_contract: String,
    price: Coin,
    duration: u64,
) -> StdResult<CosmosMsg> {
    Ok(CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract.to_string(),
        msg: to_json_binary(&ExecuteMsg::List {
            token_id,
            contract_addr: nft_contract,
            price,
            duration,
        })?,
        funds: vec![],
    }))
}

pub fn buy_nft_msg(
    contract: Addr,
    token_id: String,
    nft_contract: String,
    funds: Vec<Coin>,
) -> StdResult<CosmosMsg> {
    Ok(CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract.to_string(),
        msg: to_json_binary(&ExecuteMsg::Buy {
            token_id,
            contract_addr: nft_contract,
        })?,
        funds,
    }))
}

pub fn make_offer_msg(
    contract: Addr,
    token_id: String,
    nft_contract: String,
    offer: Coin,
    duration: u64,
) -> StdResult<CosmosMsg> {
    Ok(CosmosMsg::Wasm(WasmMsg::Execute {
        contract_addr: contract.to_string(),
        msg: to_json_binary(&ExecuteMsg::MakeOffer {
            token_id,
            contract_addr: nft_contract,
            offer,
            duration,
        })?,
        funds: vec![offer],
    }))
}

src/tests.rs

#[cfg(test)]
mod tests {
    use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
    use cosmwasm_std::{from_json, Coin, Uint128};
    use crate::contract::{instantiate, execute, query};
    use crate::msg::{
        InstantiateMsg, ExecuteMsg, QueryMsg, ConfigResponse,
    };

    fn setup_contract(deps: &mut cosmwasm_std::OwnedDeps<cosmwasm_std::MemoryStorage, cosmwasm_std::testing::MockApi, cosmwasm_std::testing::MockQuerier>) {
        let env = mock_env();
        let info = mock_info("owner", &[]);
        let msg = InstantiateMsg {
            fee_percentage: 250,
            fee_recipient: "fee_wallet".to_string(),
            accepted_denoms: vec!["umsg".to_string()],
        };
        instantiate(deps.as_mut(), env, info, msg).unwrap();
    }

    #[test]
    fn proper_instantiation() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        let info = mock_info("owner", &[]);
        let msg = InstantiateMsg {
            fee_percentage: 250,
            fee_recipient: "fee_wallet".to_string(),
            accepted_denoms: vec!["umsg".to_string()],
        };
        let res = instantiate(deps.as_mut(), env, info, msg).unwrap();
        assert_eq!(res.attributes[1].value, "250");
    }

    #[test]
    fn list_nft() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        setup_contract(&mut deps);

        let info = mock_info("seller", &[]);
        let list_msg = ExecuteMsg::List {
            token_id: "1".to_string(),
            contract_addr: "nft_contract".to_string(),
            price: Coin {
                denom: "umsg".to_string(),
                amount: Uint128::from(1000000u128),
            },
            duration: 86400,
        };

        let res = execute(deps.as_mut(), env, info, list_msg);
        assert!(res.is_ok());
    }

    #[test]
    fn unsupported_denom() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        setup_contract(&mut deps);

        let info = mock_info("seller", &[]);
        let list_msg = ExecuteMsg::List {
            token_id: "1".to_string(),
            contract_addr: "nft_contract".to_string(),
            price: Coin {
                denom: "uusd".to_string(),
                amount: Uint128::from(1000000u128),
            },
            duration: 86400,
        };

        let err = execute(deps.as_mut(), env, info, list_msg).unwrap_err();
        assert_eq!(err.to_string(), "Unsupported denomination");
    }

    #[test]
    fn config_query() {
        let mut deps = mock_dependencies();
        let env = mock_env();
        setup_contract(&mut deps);

        let res: ConfigResponse = from_json(
            query(deps.as_ref(), env, QueryMsg::Config {}).unwrap()
        ).unwrap();

        assert_eq!(res.fee_percentage, 250);
        assert_eq!(res.fee_recipient, "fee_wallet");
    }
}

4. Part 3: 前端(React + TypeScript + Tailwind)

项目初始化

npm create vite@latest msg-nft-marketplace -- --template react-ts
cd msg-nft-marketplace
npm install @cosmjs/cosmwasm-stargate @cosmjs/stargate @cosmjs/proto-signing \
  @cosmjs/encoding @cosmjs/math @cosmjs/amino \
  react-router-dom react-hot-toast
npm install -D tailwindcss @tailwindcss/vite postcss autoprefixer

前端文件结构

src/
├── main.tsx
├── App.tsx
├── index.css
├── config/
│   ├── chain.ts
│   ├── contracts.ts
│   └── ipfs.ts
├── hooks/
│   ├── useWallet.ts
│   ├── useNFT.ts
│   ├── useMarketplace.ts
│   └── useIPFS.ts
├── lib/
│   ├── cosmjs.ts
│   ├── nftClient.ts
│   ├── marketplaceClient.ts
│   ├── ipfsClient.ts
│   ├── utils.ts
│   └── types.ts
├── components/
│   ├── layout/   (Header, Footer, Layout)
│   ├── nft/      (NFTCard, NFTGrid, NFTAttributes)
│   ├── marketplace/ (ListingCard, OfferList)
│   └── ui/       (Button, Modal, Spinner, WalletConnect)
├── pages/
│   ├── Home.tsx
│   ├── Explore.tsx
│   ├── NFTDetailPage.tsx
│   ├── MintNFT.tsx
│   ├── MyCollection.tsx
│   └── CreatorDashboard.tsx

src/config/chain.ts

export const CHAIN_CONFIG = {
  chainId: "msg-chain-1",
  chainName: "MSG Chain",
  rpc: "https://rpc.msgchain.org",
  rest: "https://rest.msgchain.org",
  bech32Prefix: "msg",
  coinType: 118,
  stakeCurrency: { coinDenom: "MSG", coinMinDenom: "umsg", coinDecimals: 18 },
  feeCurrencies: [{
    coinDenom: "MSG", coinMinDenom: "umsg", coinDecimals: 18,
    gasPriceStep: { low: 1_000_000_000, average: 1_000_000_000, high: 1_000_000_000 },
  }],
  currencies: [{ coinDenom: "MSG", coinMinDenom: "umsg", coinDecimals: 18 }],
  features: ["cosmwasm"],
  bip44: { coinType: 118 },
};

src/config/ipfs.ts

export const IPFS_CONFIG = {
  gateway: "https://ipfs.io/ipfs/",
  pinata: { jwt: "", gateway: "https://gateway.pinata.cloud/ipfs/" },
  nftStorage: { apiKey: "", gateway: "https://nftstorage.link/ipfs/" },
  local: { url: "http://localhost:5001/api/v0" },
};
export type IPFSProvider = "pinata" | "nftstorage" | "local";

src/lib/types.ts

export interface NFTMetadata {
  name: string; description: string; image: string;
  external_url?: string; animation_url?: string; youtube_url?: string;
  attributes?: NFTAttribute[]; background_color?: string;
  royalty_info?: RoyaltyInfo;
}
export interface NFTAttribute { display_type?: string; trait_type: string; value: string; max_value?: string; }
export interface RoyaltyInfo { payment_address: string; share: string; }
export interface NFToken { token_id: string; contract_addr: string; owner: string; metadata: NFTMetadata; token_uri: string; }
export interface Listing { token_id: string; contract_addr: string; seller: string; price: Coin; duration: string; created_at: string; status: ListingStatus; }
export enum ListingStatus { Active = "active", Sold = "sold", Cancelled = "cancelled", Expired = "expired" }
export interface Offer { token_id: string; contract_addr: string; offerer: string; offer: Coin; expiration: string; created_at: string; }
export interface Coin { denom: string; amount: string; }
export interface WalletInfo { address: string; balance: Coin[]; isConnected: boolean; }

src/lib/utils.ts

import { Coin } from "./types";
export function formatCoin(c: Coin): string {
  return `${(parseFloat(c.amount) / 1e18).toLocaleString()} ${c.denom.replace("u","").toUpperCase()}`;
}
export function formatAddress(a: string, chars = 6): string {
  return a ? `${a.slice(0,chars)}...${a.slice(-chars)}` : "";
}
export function getTimeAgo(ts: number): string {
  const s = Math.floor((Date.now() - ts*1000)/1000);
  if (s < 60) return `${s}秒前`;
  if (s < 3600) return `${Math.floor(s/60)}分钟前`;
  if (s < 86400) return `${Math.floor(s/3600)}小时前`;
  return `${Math.floor(s/86400)}天前`;
}

src/lib/cosmjs.ts

import { SigningCosmWasmClient, CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { GasPrice } from "@cosmjs/stargate";
import { CHAIN_CONFIG } from "../config/chain";
let client: SigningCosmWasmClient | null = null;
export async function getClient(): Promise<SigningCosmWasmClient> {
  if (client) return client;
  client = await SigningCosmWasmClient.connectWithSigner(
    CHAIN_CONFIG.rpc, window.keplr!.getOfflineSigner(CHAIN_CONFIG.chainId),
    { gasPrice: GasPrice.fromString("1000000000attoMSG") }
  );
  return client;
}
export function resetClient(): void { client = null; }
export async function getQueryClient(): Promise<CosmWasmClient> {
  return CosmWasmClient.connect(CHAIN_CONFIG.rpc);
}

src/lib/nftClient.ts

import { getClient, getQueryClient } from "./cosmjs";
import { NFToken } from "./types";

export class NFTClient {
  constructor(private contractAddress: string) {}

  async queryNftInfo(tokenId: string): Promise<NFToken> {
    const c = await getQueryClient();
    const [info, owner]: any = await Promise.all([
      c.queryContractSmart(this.contractAddress, { nft_info: { token_id: tokenId } }),
      c.queryContractSmart(this.contractAddress, { owner_of: { token_id: tokenId } }),
    ]);
    return {
      token_id: tokenId, contract_addr: this.contractAddress,
      owner: owner.owner,
      metadata: info.extension || { name: `#${tokenId}`, description: "", image: "" },
      token_uri: info.token_uri || "",
    };
  }

  async queryTokens(owner: string, limit = 30): Promise<string[]> {
    const c = await getQueryClient();
    const r: any = await c.queryContractSmart(this.contractAddress, { tokens: { owner, limit } });
    return r.tokens;
  }

  async mint(sender: string, tokenId: string, owner: string, uri: string, ext: any) {
    const c = await getClient();
    return c.execute(sender, this.contractAddress, { mint: { token_id: tokenId, owner, token_uri: uri, extension: ext } }, "auto");
  }
}

src/lib/marketplaceClient.ts

import { Coin } from "@cosmjs/stargate";
import { getClient, getQueryClient } from "./cosmjs";
import { Listing, Offer } from "./types";

export class MarketplaceClient {
  constructor(private contractAddress: string) {}

  async queryListings(filter?: any, limit = 30): Promise<Listing[]> {
    const c = await getQueryClient();
    const r: any = await c.queryContractSmart(this.contractAddress, { listings: { limit, filter } });
    return r.listings || [];
  }

  async queryListing(tokenId: string, contractAddr: string): Promise<Listing> {
    const c = await getQueryClient();
    return c.queryContractSmart(this.contractAddress, { listing: { token_id: tokenId, contract_addr: contractAddr } });
  }

  async queryOffers(tokenId: string, contractAddr: string): Promise<Offer[]> {
    const c = await getQueryClient();
    const r: any = await c.queryContractSmart(this.contractAddress, { offers: { token_id: tokenId, contract_addr: contractAddr } });
    return r.offers || [];
  }

  async list(sender: string, tokenId: string, nftContract: string, price: Coin, duration: number) {
    const c = await getClient();
    return c.execute(sender, this.contractAddress, { list: { token_id: tokenId, contract_addr: nftContract, price, duration } }, "auto");
  }

  async buy(sender: string, tokenId: string, nftContract: string, funds: Coin[]) {
    const c = await getClient();
    return c.execute(sender, this.contractAddress, { buy: { token_id: tokenId, contract_addr: nftContract } }, "auto", funds);
  }

  async makeOffer(sender: string, tokenId: string, nftContract: string, offer: Coin, duration: number) {
    const c = await getClient();
    return c.execute(sender, this.contractAddress, { make_offer: { token_id: tokenId, contract_addr: nftContract, offer, duration } }, "auto", [offer]);
  }

  async acceptOffer(sender: string, tokenId: string, nftContract: string, offerer: string) {
    const c = await getClient();
    return c.execute(sender, this.contractAddress, { accept_offer: { token_id: tokenId, contract_addr: nftContract, offerer } }, "auto");
  }
}

src/lib/ipfsClient.ts

import { IPFS_CONFIG, IPFSProvider } from "../config/ipfs";

export class IPFSClient {
  constructor(private provider: IPFSProvider = "pinata") {}

  async uploadFile(file: File): Promise<string> {
    if (this.provider === "pinata") return this.pinata(file);
    if (this.provider === "nftstorage") return this.nftStorage(file);
    return this.local(file);
  }

  async uploadJSON(data: any): Promise<string> {
    return this.uploadFile(new File([new Blob([JSON.stringify(data)])], "meta.json"));
  }

  private async pinata(file: File): Promise<string> {
    const fd = new FormData(); fd.append("file", file);
    fd.append("pinataMetadata", JSON.stringify({ name: file.name }));
    fd.append("pinataOptions", JSON.stringify({ cidVersion: 1 }));
    const r = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
      method: "POST", headers: { Authorization: `Bearer ${IPFS_CONFIG.pinata.jwt}` }, body: fd,
    });
    const d = await r.json(); return d.IpfsHash;
  }

  private async nftStorage(file: File): Promise<string> {
    const fd = new FormData(); fd.append("file", file);
    const r = await fetch("https://api.nft.storage/upload", {
      method: "POST", headers: { Authorization: `Bearer ${IPFS_CONFIG.nftStorage.apiKey}` }, body: fd,
    });
    const d = await r.json(); return d.value.cid;
  }

  private async local(file: File): Promise<string> {
    const fd = new FormData(); fd.append("file", file);
    const r = await fetch(`${IPFS_CONFIG.local.url}/add`, { method: "POST", body: fd });
    const d = await r.json(); return d.Hash;
  }

  getUrl(cid: string): string { return `${IPFS_CONFIG.gateway}${cid}`; }
}

src/hooks/useWallet.ts

import { useState, useCallback } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";

declare global { interface Window { keplr?: any } }

export function useWallet() {
  const [wallet, setWallet] = useState({ address: "", balance: [] as any[], isConnected: false });
  const [loading, setLoading] = useState(false);

  const connectKeplr = useCallback(async () => {
    setLoading(true);
    try {
      if (!window.keplr) throw new Error("请安装 Keplr");
      await window.keplr.experimentalSuggestChain(CHAIN_CONFIG);
      await window.keplr.enable(CHAIN_CONFIG.chainId);
      const signer = window.keplr.getOfflineSigner(CHAIN_CONFIG.chainId);
      const [acc] = await signer.getAccounts();
      const c = await SigningCosmWasmClient.connectWithSigner(CHAIN_CONFIG.rpc, signer);
      const bal = await c.getBalance(acc.address, "umsg");
      setWallet({ address: acc.address, balance: [bal], isConnected: true });
    } catch (e: any) { console.error(e); } finally { setLoading(false); }
  }, []);

  const disconnect = useCallback(() => {
    setWallet({ address: "", balance: [], isConnected: false });
  }, []);

  return { wallet, loading, connectKeplr, disconnect };
}

src/components/ui/Button.tsx

import React from "react";

export function Button({ variant = "primary", size = "md", loading, fullWidth, children, ...props }: any) {
  const v = {
    primary: "bg-purple-600 text-white hover:bg-purple-700",
    outline: "border border-gray-600 text-gray-300 hover:bg-gray-800",
    danger: "bg-red-600 text-white hover:bg-red-700",
  };
  const s = { sm: "px-3 py-1.5 text-sm", md: "px-4 py-2 text-sm", lg: "px-6 py-3 text-base" };
  return (
    <button className={`inline-flex items-center justify-center font-medium rounded-lg transition-colors disabled:opacity-50 ${v[variant as keyof typeof v] || v.primary} ${s[size as keyof typeof s] || s.md} ${fullWidth ? "w-full" : ""}`}
      disabled={loading || props.disabled} {...props}>
      {loading && <svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
      {children}
    </button>
  );
}

src/components/ui/WalletConnect.tsx

import React from "react";
import { Button } from "./Button";
import { formatAddress } from "../../lib/utils";

export function WalletConnect({ wallet, loading, onConnect, onDisconnect }: any) {
  if (wallet.isConnected) {
    return (
      <div className="flex items-center gap-3">
        <span className="text-sm text-gray-400">{(parseFloat(wallet.balance[0]?.amount || "0") / 1e18).toFixed(2)} MSG</span>
        <div className="relative group">
          <button className="px-4 py-2 bg-gray-800 rounded-lg border border-gray-700 hover:border-purple-500">
            <span className="w-2 h-2 rounded-full bg-green-500 inline-block mr-2" />
            <span className="text-sm text-gray-200">{formatAddress(wallet.address)}</span>
          </button>
          <div className="absolute right-0 mt-2 w-48 py-2 bg-gray-800 rounded-lg border border-gray-700 shadow-xl hidden group-hover:block">
            <button onClick={onDisconnect} className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-gray-700">断开连接</button>
          </div>
        </div>
      </div>
    );
  }
  return <Button onClick={onConnect} loading={loading}>连接钱包</Button>;
}

src/components/nft/NFTCard.tsx

import React from "react";
import { Link } from "react-router-dom";
import { formatAddress } from "../../lib/utils";

export function NFTCard({ nft }: any) {
  const img = nft.metadata.image?.startsWith("ipfs://")
    ? `https://ipfs.io/ipfs/${nft.metadata.image.slice(7)}` : nft.metadata.image || "/placeholder.png";
  return (
    <Link to={`/nft/${nft.contract_addr}/${nft.token_id}`}
      className="group bg-gray-800 rounded-xl border border-gray-700 overflow-hidden hover:border-purple-500/50 hover:shadow-lg transition-all">
      <div className="aspect-square bg-gray-900 overflow-hidden">
        <img src={img} alt={nft.metadata.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform"
          onError={e => { (e.target as HTMLImageElement).src = "/placeholder.png"; }} />
      </div>
      <div className="p-4">
        <h3 className="text-white font-semibold truncate">{nft.metadata.name || `#${nft.token_id}`}</h3>
        <p className="text-sm text-gray-400 mt-1 truncate">{nft.metadata.description || "无描述"}</p>
        <div className="flex justify-between mt-3 pt-3 border-t border-gray-700 text-xs">
          <span className="text-gray-500">所有者</span>
          <span className="text-gray-300">{formatAddress(nft.owner)}</span>
        </div>
      </div>
    </Link>
  );
}

src/components/nft/NFTGrid.tsx

import React from "react";
import { NFTCard } from "./NFTCard";
import { Spinner } from "../ui/Spinner";

export function NFTGrid({ nfts, loading, emptyMessage = "暂无NFT" }: any) {
  if (loading) return <div className="flex justify-center py-20"><Spinner size="lg" /></div>;
  if (!nfts?.length) return <div className="text-center py-20 text-gray-400">{emptyMessage}</div>;
  return <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
    {nfts.map((nft: any) => <NFTCard key={`${nft.contract_addr}-${nft.token_id}`} nft={nft} />)}
  </div>;
}

src/components/nft/NFTAttributes.tsx

import React from "react";
export function NFTAttributes({ attributes }: any) {
  if (!attributes?.length) return <div className="text-gray-500 text-sm">无属性</div>;
  return <div className="grid grid-cols-2 gap-3">
    {attributes.map((a: any, i: number) => (
      <div key={i} className="bg-gray-800 border border-gray-700 rounded-lg p-3">
        <p className="text-xs text-gray-400 mb-1">{a.trait_type}</p>
        <p className="text-sm text-white font-medium">{a.value}</p>
      </div>
    ))}
  </div>;
}

src/components/marketplace/ListingCard.tsx

import React from "react";
import { formatAddress, formatCoin, getTimeAgo } from "../../lib/utils";

export function ListingCard({ listing, onBuy, onUnlist, isOwner }: any) {
  return (
    <div className="bg-gray-800 rounded-lg border border-gray-700 p-4">
      <div className="flex justify-between mb-2">
        <span className="text-sm text-gray-400">#{listing.token_id}</span>
        <span className={`px-2 py-0.5 text-xs rounded-full ${listing.status === "active" ? "bg-green-500/20 text-green-400" : "bg-gray-700 text-gray-400"}`}>
          {listing.status === "active" ? "出售中" : "已下架"}
        </span>
      </div>
      <div className="text-lg font-bold text-white mb-1">{formatCoin(listing.price)}</div>
      <div className="text-xs text-gray-500 mb-3">{formatAddress(listing.seller)} · {getTimeAgo(Number(listing.created_at))}</div>
      {listing.status === "active" && (
        <div className="flex gap-2">
          {!isOwner && onBuy && <button onClick={onBuy} className="flex-1 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg">立即购买</button>}
          {isOwner && onUnlist && <button onClick={onUnlist} className="flex-1 py-2 bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm rounded-lg">取消出售</button>}
        </div>
      )}
    </div>
  );
}

src/components/marketplace/OfferList.tsx

import React from "react";
import { formatAddress, formatCoin, getTimeAgo } from "../../lib/utils";

export function OfferList({ offers, onAccept, isOwner, currentUser }: any) {
  if (!offers?.length) return <div className="text-center py-8 text-gray-500">暂无出价</div>;
  return <div className="space-y-3">
    {offers.map((o: any, i: number) => (
      <div key={i} className="flex items-center justify-between bg-gray-800 border border-gray-700 rounded-lg p-4">
        <div>
          <div className="text-lg font-semibold text-white">{formatCoin(o.offer)}</div>
          <div className="text-sm text-gray-400">{formatAddress(o.offerer)} · {getTimeAgo(Number(o.created_at))}</div>
        </div>
        <div className="flex gap-2">
          {isOwner && onAccept && <button onClick={() => onAccept(o.offerer)} className="px-3 py-2 bg-green-600 text-white text-sm rounded-lg">接受</button>}
          {o.offerer === currentUser && <button className="px-3 py-2 bg-red-600 text-white text-sm rounded-lg">取消</button>}
        </div>
      </div>
    ))}
  </div>;
}

src/components/layout/Header.tsx

import React from "react";
import { Link } from "react-router-dom";
import { WalletConnect } from "../ui/WalletConnect";

export function Header({ wallet, walletLoading, onConnect, onDisconnect }: any) {
  return (
    <header className="sticky top-0 z-40 bg-gray-900/80 backdrop-blur-lg border-b border-gray-800">
      <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
        <div className="flex items-center gap-8">
          <Link to="/" className="text-xl font-bold text-white">MSG<span className="text-purple-500">NFT</span></Link>
          <nav className="hidden md:flex gap-6 text-sm">
            <Link to="/explore" className="text-gray-300 hover:text-white">探索</Link>
            <Link to="/mint" className="text-gray-300 hover:text-white">铸造</Link>
            <Link to="/my-collection" className="text-gray-300 hover:text-white">我的收藏</Link>
            <Link to="/creator-dashboard" className="text-gray-300 hover:text-white">创作者中心</Link>
          </nav>
        </div>
        <WalletConnect wallet={wallet} loading={walletLoading} onConnect={onConnect} onDisconnect={onDisconnect} />
      </div>
    </header>
  );
}

src/components/layout/Layout.tsx

import React from "react";
import { Outlet } from "react-router-dom";
import { Header } from "./Header";
import { Footer } from "./Footer";

export function Layout({ wallet, walletLoading, onConnect, onDisconnect }: any) {
  return (
    <div className="min-h-screen bg-gray-950 text-white flex flex-col">
      <Header {...{ wallet, walletLoading, onConnect, onDisconnect }} />
      <main className="flex-1"><Outlet /></main>
      <Footer />
    </div>
  );
}

src/components/layout/Footer.tsx

import React from "react";
export function Footer() {
  return (
    <footer className="border-t border-gray-800 mt-auto py-8 text-center text-sm text-gray-500">
      &copy; 2026 MSG NFT Marketplace
    </footer>
  );
}

src/components/ui/Spinner.tsx

import React from "react";
export function Spinner({ size = "md" }: any) {
  const s: any = { sm: "h-4 w-4", md: "h-8 w-8", lg: "h-12 w-12" };
  return (
    <svg className={`animate-spin text-purple-500 ${s[size] || s.md}`} fill="none" viewBox="0 0 24 24">
      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
    </svg>
  );
}

src/pages/Home.tsx

import React from "react";
import { Link } from "react-router-dom";
import { Button } from "../components/ui/Button";

export function Home() {
  return (
    <div>
      <section className="relative py-20 text-center bg-gradient-to-br from-purple-900/30 to-gray-900">
        <h1 className="text-5xl font-bold text-white mb-6">MSG Chain NFT 市场</h1>
        <p className="text-xl text-gray-300 mb-8 max-w-2xl mx-auto">发现、铸造和交易独一无二的数字资产。类似 OpenSea 的去中心化 NFT 市场。</p>
        <div className="flex justify-center gap-4">
          <Link to="/explore"><Button size="lg">探索NFT</Button></Link>
          <Link to="/mint"><Button variant="outline" size="lg">铸造NFT</Button></Link>
        </div>
      </section>
    </div>
  );
}

src/pages/Explore.tsx

import React, { useEffect, useState } from "react";
import { NFTGrid } from "../components/nft/NFTGrid";
import { NFTClient } from "../lib/nftClient";
import { MarketplaceClient } from "../lib/marketplaceClient";

export function Explore() {
  const [nfts, setNfts] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    (async () => {
      setLoading(true);
      try {
        const mc = new MarketplaceClient("");
        const listings = await mc.queryListings({ active: true });
        const results = await Promise.all(listings.map(async (l: any) => {
          try { return await new NFTClient(l.contract_addr).queryNftInfo(l.token_id); } catch { return null; }
        }));
        setNfts(results.filter(Boolean));
      } catch (e) { console.error(e); } finally { setLoading(false); }
    })();
  }, []);
  return <div className="max-w-7xl mx-auto px-4 py-8"><h1 className="text-3xl font-bold text-white mb-8">探索NFT</h1><NFTGrid nfts={nfts} loading={loading} emptyMessage="暂无在售NFT" /></div>;
}

src/pages/NFTDetailPage.tsx

import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { NFTClient } from "../lib/nftClient";
import { MarketplaceClient } from "../lib/marketplaceClient";
import { useWallet } from "../hooks/useWallet";
import { NFTAttributes } from "../components/nft/NFTAttributes";
import { Button } from "../components/ui/Button";
import { Spinner } from "../components/ui/Spinner";
import { formatAddress } from "../lib/utils";

export function NFTDetailPage() {
  const { contractAddr, tokenId } = useParams();
  const { wallet } = useWallet();
  const [nft, setNft] = useState<any>(null);
  const [listing, setListing] = useState<any>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => { if (contractAddr && tokenId) load(); }, [contractAddr, tokenId]);
  async function load() {
    setLoading(true);
    try {
      const nc = new NFTClient(contractAddr!);
      const mc = new MarketplaceClient("");
      setNft(await nc.queryNftInfo(tokenId!));
      try { setListing(await mc.queryListing(tokenId!, contractAddr!)); } catch { setListing(null); }
    } catch (e) { console.error(e); } finally { setLoading(false); }
  }

  if (loading) return <div className="flex justify-center py-20"><Spinner size="lg" /></div>;
  if (!nft) return <div className="text-center py-20 text-gray-400">NFT 未找到</div>;

  const img = nft.metadata.image?.startsWith("ipfs://") ? `https://ipfs.io/ipfs/${nft.metadata.image.slice(7)}` : nft.metadata.image || "/placeholder.png";
  const isOwner = wallet.isConnected && wallet.address === nft.owner;

  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <div className="grid grid-cols-1 lg:grid-cols-5 gap-8">
        <div className="lg:col-span-3">
          <div className="rounded-2xl overflow-hidden bg-gray-800 border border-gray-700">
            <img src={img} alt={nft.metadata.name} className="w-full object-cover" />
          </div>
        </div>
        <div className="lg:col-span-2 space-y-6">
          <h1 className="text-3xl font-bold text-white">{nft.metadata.name || `#${nft.token_id}`}</h1>
          <div className="bg-gray-800 rounded-xl border border-gray-700 p-4">
            <p className="text-gray-400 text-sm">所有者</p>
            <p className="text-white font-mono">{formatAddress(nft.owner, 10)}</p>
          </div>
          <p className="text-gray-300">{nft.metadata.description || "无描述"}</p>
          {listing?.status === "active" && (
            <div className="bg-gray-800 rounded-lg border border-gray-700 p-4">
              <p className="text-2xl font-bold text-white">{(parseFloat(listing.price.amount)/1e18).toFixed(2)} MSG</p>
              {!isOwner && wallet.isConnected && <Button onClick={async () => {
                const mc = new MarketplaceClient("");
                await mc.buy(wallet.address, tokenId!, contractAddr!, [listing.price]);
                load();
              }} fullWidth className="mt-3">立即购买</Button>}
            </div>
          )}
          {nft.metadata.attributes && <NFTAttributes attributes={nft.metadata.attributes} />}
        </div>
      </div>
    </div>
  );
}

src/pages/MintNFT.tsx

import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useWallet } from "../hooks/useWallet";
import { useIPFS } from "../hooks/useIPFS";
import { NFTClient } from "../lib/nftClient";
import { Button } from "../components/ui/Button";

export function MintNFT() {
  const navigate = useNavigate();
  const { wallet } = useWallet();
  const { uploadFile, uploadMetadata, uploading } = useIPFS("pinata");
  const [name, setName] = useState("");
  const [desc, setDesc] = useState("");
  const [file, setFile] = useState<File | null>(null);
  const [nftAddr, setNftAddr] = useState("");
  const [minting, setMinting] = useState(false);

  async function handleMint() {
    if (!wallet.isConnected || !name || !file || !nftAddr) return;
    setMinting(true);
    try {
      const imgCid = await uploadFile(file);
      const metaCid = await uploadMetadata({ name, description: desc, image: `ipfs://${imgCid}` });
      const id = Date.now().toString();
      const res = await new NFTClient(nftAddr).mint(wallet.address, id, wallet.address, `ipfs://${metaCid}`, { name, description: desc, image: `ipfs://${imgCid}` });
      alert(`铸造成功! TX: ${res.transactionHash}`);
      navigate(`/nft/${nftAddr}/${id}`);
    } catch (e: any) { alert(e.message); } finally { setMinting(false); }
  }

  return (
    <div className="max-w-3xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold text-white mb-8">铸造 NFT</h1>
      <div className="space-y-6">
        <div>
          <label className="block text-sm text-gray-400 mb-2">NFT 合约地址</label>
          <input value={nftAddr} onChange={e => setNftAddr(e.target.value)} placeholder="msg1..." className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white" />
        </div>
        <div>
          <label className="block text-sm text-gray-400 mb-2">名称</label>
          <input value={name} onChange={e => setName(e.target.value)} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white" />
        </div>
        <div>
          <label className="block text-sm text-gray-400 mb-2">描述</label>
          <textarea value={desc} onChange={e => setDesc(e.target.value)} rows={3} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white" />
        </div>
        <div>
          <label className="block text-sm text-gray-400 mb-2">图片</label>
          <input type="file" accept="image/*" onChange={e => setFile(e.target.files?.[0] || null)} />
        </div>
        <Button onClick={handleMint} loading={minting || uploading} size="lg" fullWidth>铸造 NFT</Button>
      </div>
    </div>
  );
}

src/pages/MyCollection.tsx

import React, { useEffect, useState } from "react";
import { useWallet } from "../hooks/useWallet";
import { NFTGrid } from "../components/nft/NFTGrid";
import { Button } from "../components/ui/Button";
import { Link } from "react-router-dom";

export function MyCollection() {
  const { wallet, connectKeplr } = useWallet();
  if (!wallet.isConnected) return <div className="text-center py-20"><h1 className="text-3xl font-bold text-white mb-4">我的收藏</h1><Button onClick={connectKeplr}>连接钱包</Button></div>;
  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold text-white">我的收藏</h1>
        <Link to="/mint"><Button>铸造新 NFT</Button></Link>
      </div>
      <NFTGrid nfts={[]} loading={false} emptyMessage="暂无 NFT" />
    </div>
  );
}

src/pages/CreatorDashboard.tsx

import React, { useEffect, useState } from "react";
import { useWallet } from "../hooks/useWallet";
import { MarketplaceClient } from "../lib/marketplaceClient";
import { Button } from "../components/ui/Button";
import { Spinner } from "../components/ui/Spinner";

export function CreatorDashboard() {
  const { wallet, connectKeplr } = useWallet();
  const [listings, setListings] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!wallet.isConnected) return;
    (async () => { setLoading(true); try { setListings(await new MarketplaceClient("").queryListings({ by_seller: wallet.address })); } catch (e) { console.error(e); } finally { setLoading(false); } })();
  }, [wallet.address]);

  if (!wallet.isConnected) return <div className="text-center py-20"><h1 className="text-3xl font-bold text-white mb-4">创作者中心</h1><Button onClick={connectKeplr}>连接钱包</Button></div>;
  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold text-white mb-8">创作者中心</h1>
      <div className="grid grid-cols-3 gap-6 mb-8">
        <div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
          <p className="text-gray-400 text-sm">销售量</p>
          <p className="text-3xl font-bold text-white">{listings.filter((l: any) => l.status === "sold").length}</p>
        </div>
        <div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
          <p className="text-gray-400 text-sm">活跃上架</p>
          <p className="text-3xl font-bold text-white">{listings.filter((l: any) => l.status === "active").length}</p>
        </div>
        <div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
          <p className="text-gray-400 text-sm">总收入</p>
          <p className="text-3xl font-bold text-white">{(listings.filter((l: any) => l.status === "sold").reduce((a: number, l: any) => a + parseFloat(l.price.amount), 0) / 1e18).toFixed(2)} MSG</p>
        </div>
      </div>
      {loading ? <Spinner /> : !listings.length ? <p className="text-center text-gray-500">暂无数据</p> : (
        <div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
          <table className="w-full">
            <thead><tr className="text-left text-gray-400 text-sm border-b border-gray-700">
              <th className="px-6 py-4">Token</th><th className="px-6 py-4">价格</th><th className="px-6 py-4">状态</th>
            </tr></thead>
            <tbody>{listings.map((l: any, i: number) => (
              <tr key={i} className="border-b border-gray-700/50 hover:bg-gray-700/30">
                <td className="px-6 py-4 text-white font-mono">{l.token_id}</td>
                <td className="px-6 py-4 text-white">{(parseFloat(l.price.amount)/1e18).toFixed(2)} MSG</td>
                <td className="px-6 py-4"><span className={`px-2 py-1 text-xs rounded-full ${l.status === "active" ? "bg-green-500/20 text-green-400" : "bg-gray-700 text-gray-400"}`}>{l.status === "active" ? "出售中" : l.status === "sold" ? "已售出" : "已取消"}</span></td>
              </tr>
            ))}</tbody>
          </table>
        </div>
      )}
    </div>
  );
}

src/App.tsx

import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Layout } from "./components/layout/Layout";
import { Home } from "./pages/Home";
import { Explore } from "./pages/Explore";
import { NFTDetailPage } from "./pages/NFTDetailPage";
import { MintNFT } from "./pages/MintNFT";
import { MyCollection } from "./pages/MyCollection";
import { CreatorDashboard } from "./pages/CreatorDashboard";
import { useWallet } from "./hooks/useWallet";

export default function App() {
  const { wallet, loading, connectKeplr, disconnect } = useWallet();
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<Layout wallet={wallet} walletLoading={loading} onConnect={connectKeplr} onDisconnect={disconnect} />}>
          <Route path="/" element={<Home />} />
          <Route path="/explore" element={<Explore />} />
          <Route path="/nft/:contractAddr/:tokenId" element={<NFTDetailPage />} />
          <Route path="/mint" element={<MintNFT />} />
          <Route path="/my-collection" element={<MyCollection />} />
          <Route path="/creator-dashboard" element={<CreatorDashboard />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

src/main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<React.StrictMode><App /></React.StrictMode>);

src/index.css

@import "tailwindcss";
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background-color: #030712; color: #f3f4f6; }

vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ plugins: [react(), tailwindcss()], server: { port: 3000 } });

5. Part 4: 元数据与存储

IPFS 集成

NFT 的元数据和媒体文件存储在去中心化存储上。推荐 NFT.Storage(免费)或 Pinata(生产环境)。

// NFT.Storage 上传
async function uploadNFTStorage(file: File): Promise<string> {
  const fd = new FormData(); fd.append("file", file);
  const r = await fetch("https://api.nft.storage/upload", {
    method: "POST", headers: { Authorization: "Bearer YOUR_KEY" }, body: fd,
  });
  const d = await r.json(); return `ipfs://${d.value.cid}`;
}

// Pinata 上传
async function uploadPinata(file: File): Promise<string> {
  const fd = new FormData(); fd.append("file", file);
  fd.append("pinataMetadata", JSON.stringify({ name: file.name }));
  const r = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
    method: "POST", headers: { Authorization: "Bearer YOUR_JWT" }, body: fd,
  });
  const d = await r.json(); return `ipfs://${d.IpfsHash}`;
}

链上元数据格式(MetadataExtension)

{
  "name": "My NFT",
  "description": "在 MSG Chain 上的第一个 NFT",
  "image": "ipfs://bafybeig.../image.png",
  "attributes": [{ "trait_type": "稀有度", "value": "传说" }],
  "royalty_info": { "payment_address": "msg1...", "share": "500" },
  "is_soulbound": false
}

链下元数据(OpenSea 标准)

{
  "name": "My NFT",
  "description": "在 MSG Chain 上的第一个 NFT",
  "image": "ipfs://bafybeig.../image.png",
  "attributes": [{ "trait_type": "稀有度", "value": "传说" }]
}

6. Part 5: 部署

合约编译

cargo wasm
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/optimizer:0.15.1

部署脚本

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

async function deploy(wasmPath, label, msg) {
  const wallet = await DirectSecp256k1HdWallet.fromMnemonic(process.env.MNEMONIC, { prefix: "msg" });
  const client = await SigningCosmWasmClient.connectWithSigner("https://rpc.msgchain.org", wallet,
    { gasPrice: GasPrice.fromString("1000000000attoMSG") });
  const [acc] = await wallet.getAccounts();
  const upload = await client.upload(acc.address, fs.readFileSync(wasmPath), "auto");
  console.log(`Code ID: ${upload.codeId}`);
  const inst = await client.instantiate(acc.address, upload.codeId, msg, label, "auto");
  console.log(`合约地址: ${inst.contractAddress}`);
  return { codeId: upload.codeId, address: inst.contractAddress };
}

// NFT 合约
await deploy("artifacts/msg_nft.wasm", "MSG NFT", {
  name: "My Collection", symbol: "MYNFT", minter: "msg1...",
  collection_info: { creator: "msg1...", description: "我的收藏", image: "" },
});

// 市场合约
await deploy("artifacts/msg_marketplace.wasm", "MSG Marketplace", {
  fee_percentage: 250, fee_recipient: "msg1...", accepted_denoms: ["umsg"],
});

Docker Compose

version: "3.8"
services:
  ipfs:
    image: ipfs/kubo:latest
    ports: ["5001:5001", "8080:8080"]
    volumes: [ipfs-data:/data/ipfs]
  frontend:
    build: ./frontend
    ports: ["3000:80"]
volumes:
  ipfs-data:

前端 Dockerfile

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci && npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

合约验证

cosmwasm-check artifacts/msg_nft.wasm
cosmwasm-check artifacts/msg_marketplace.wasm

总结

本指南完整涵盖 MSG Chain NFT 市场 dApp 的全部组件:

组件 技术栈 说明
NFT 合约 Rust + cw721 铸造、销毁、转移、版税、Soulbound
市场合约 Rust + CosmWasm 上架、购买、出价、版税分账
前端 React + TypeScript + Tailwind 类似 OpenSea 的完整 UI
存储 IPFS 分布式元数据与媒体存储
部署 Docker Compose + CosmJS 一键部署到 MSG Chain

整个系统完全去中心化:NFT 资产由 cw721 智能合约管理,交易通过市场合约在链上进行,元数据存储在 IPFS 上,用户通过 Keplr 钱包自行托管资产。支持 EIP-2981 兼容的版税系统和 Soulbound(不可转移)NFT。类似 OpenSea 的功能全部在链上实现,确保用户资产的真正所有权。


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