MSG Chain NFT 铸造与市场完整 dApp 指南
本指南详细讲解如何在 MSG Chain 上构建一个完整的 NFT 铸造与市场 dApp,包含智能合约(Rust/CosmWasm)、前端(React/TypeScript/Tailwind)、元数据存储及部署。整体架构类似 OpenSea 与 Stargaze 的结合。
主网状态:No-Go
目录
- 环境概述与链配置
- Part 1: NFT 智能合约(cw721)
- Part 2: 市场智能合约
- Part 3: 前端(React + TypeScript + Tailwind)
- Part 4: 元数据与存储
- 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 秒 |
可用原语
- CosmWasm 1.x:
cosmwasm-std1.5,cw-storage-plus1.2 - cw721: CosmWasm NFT 标准(等同于 ERC-721)
- cw721-base: 参考实现
- cw721-metadata-onchain: 链上元数据扩展
- Agent API: 用于集成
- msg_token_cw20: 用于支付的 CW20 代币
开发环境准备
# 安装 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">
© 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/
