NFT 市场是去中心化交易基础设施的代表。一个安全、可组合的市场合约需要同时处理好代币标准、订单撮合、资金托管、版税分发与抗抢单。OpenSea(Seaport)、Blur、LooksRare 的交易量证明了这一赛道的重要性。本文将从标准差异讲到生产级市场合约的实现。
一、NFT 标准对比:ERC-721 vs ERC-1155
标准速览
| 维度 | ERC-721 | ERC-1155 |
|---|---|---|
| 同质性 | 非同质(1 id = 1 资产) | 半同质(id 可同质或非同质) |
| 批量操作 | 无原生批量 | safeBatchTransferFrom |
| 元数据 | tokenURI(tokenId) | uri(id) 支持模板 |
| 余额 | balanceOf(owner) | balanceOf(owner, id) |
| 授权 | approve + setApprovalForAll | setApprovalForAll(批量) |
| 应用 | PFP、加密艺术 | 游戏道具、可组合资产 |
| 通用 | OpenZeppelin ERC721 | OpenZeppelin ERC1155 |
关键方法差异
// ERC-721:ownerOf 返回唯一持有人
function ownerOf(uint256 tokenId) external view returns (address owner);
// ERC-1155:余额按 (owner, id) 查询,batch 传输带 extraData
function balanceOf(address account, uint256 id) external view returns (uint256);
function safeBatchTransferFrom(
address from, address to,
uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data
) external;
选型建议
- 单个独立艺术品 → ERC-721
- 游戏资产(多个同种道具叠加数量)→ ERC-1155
- 需要批量铸造/批量上架 → ERC-1155(一次授权,批量转移,大幅省 gas)
- 兼容 OpenSea/Blur 生态 → 两者皆可,但版税与订单标准不同
一句话:ERC-721 表达"独一无二",ERC-1155 表达"资产集合",市场合约应同时兼容两者。
二、市场合约架构:挂单/撮合/托管
模块划分
┌─────────────────────────────────────────────┐
│ NFT Marketplace │
│ │
│ ① 订单模块 │
│ Listings(链上挂单) │
│ Signed Orders(链下签名订单,EIP-712) │
│ │
│ ② 撮合模块 │
│ matchOrder / fillOrder │
│ 价格校验、过期校验、nonce 防重放 │
│ │
│ ③ 结算模块 │
│ 资金托管(pull payment) │
│ 手续费分成 + 版税(EIP-2981) │
│ 代币转移(WETH / ETH / ERC20) │
│ │
│ ④ 安全模块 │
│ ReentrancyGuard / 协议费率 / 暂停开关 │
└─────────────────────────────────────────────┘
核心设计决策
| 决策点 | 选项 | 权衡 |
|---|---|---|
| 订单存链上还是链下 | 链上挂单 / 链下签名 | 链上透明但贵;链下省 gas 但需防重放 |
| 资金托管 | 即时结算 / 托管钱包 | 即时结算简单;托管模式适合竞拍 |
| 支付资产 | ETH / WETH / ERC20 | ETH 简单;WETH 兼容订单撮合 |
| 版税强制 | 软版税(无强制)/ 硬版税(协议强制) | 软版税易被绕过,硬版税争议多 |
一句话:生产级市场 = 订单模块 + 撮合模块 + 结算模块,边界清晰才能审计安全。
三、版税与 EIP-2981
标准接口
EIP-2981 定义了统一的版税查询接口,任何市场都能通用读取:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
interface IERC2981 {
/// @notice 返回指定 tokenId 在给定成交价下的版税接收方与金额
/// @return receiver 版税接收地址
/// @return royaltyAmount 版税金额(wei)
function royaltyInfo(uint256 tokenId, uint256 salePrice)
external view returns (address receiver, uint256 royaltyAmount);
/// @notice 是否支持 ERC-2981(interface ID: 0x2a55205a)
function supportsInterface(bytes4 interfaceId) external view returns (bool);
}
实现版税
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {IERC2981} from "@openzeppelin/contracts/interfaces/IERC2981.sol";
contract RoyaltyNFT is ERC721, IERC2981 {
address public royaltyReceiver;
uint96 public royaltyBps; // 以 basis points 计(1% = 100)
constructor() ERC721("RoyaltyNFT", "RNFT") {
royaltyReceiver = msg.sender;
royaltyBps = 500; // 5%
}
function royaltyInfo(uint256, uint256 salePrice)
external view override returns (address receiver, uint256 royaltyAmount)
{
return (royaltyReceiver, (salePrice * royaltyBps) / 10_000);
}
function supportsInterface(bytes4 interfaceId)
public view virtual override(ERC721, IERC2981) returns (bool)
{
return interfaceId == 0x2a55205a || super.supportsInterface(interfaceId);
}
}
市场侧的版税支付
// 结算时:先扣手续费,再付版税,最后付卖家
function _settlePayment(address tokenContract, uint256 tokenId,
uint256 price, address seller) internal {
uint256 protocolFee = (price * protocolBps) / 10_000;
uint256 remainder = price - protocolFee;
// 读取版税
(address receiver, uint256 royaltyAmount) = IERC2981(tokenContract).royaltyInfo(tokenId, price);
if (receiver != address(0) && royaltyAmount > 0) {
remainder -= royaltyAmount;
_transferEth(receiver, royaltyAmount);
}
_transferEth(seller, remainder); // 卖家最后收款
}
⚠️ 注意:EIP-2981 只是"查询协议",不强制支付——是否强制执行取决于市场策略。部分市场支持"版税协商"(royalty negotiation),可能被低价结算绕过。
四、上架 / 下架 / 购买流程
流程设计
上架(Seller):
approve(marketplace, tokenId) 或 setApprovalForAll(marketplace, true)
→ createListing(tokenContract, tokenId, price, deadline)
→ 事件:ListingCreated
购买(Buyer):
createListing 的参数校验通过 → 转账 ETH → 转移 NFT → 分发手续费与版税
→ 事件:OrderFilled
下架(Seller):
cancelListing(tokenContract, tokenId)
→ 清除挂单 → 事件:ListingCancelled
生产级实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract Marketplace is ReentrancyGuard {
uint256 public protocolBps = 250; // 2.5%
mapping(address => mapping(uint256 => Listing)) public listings;
struct Listing {
address seller;
uint256 price;
uint256 deadline;
bool active;
}
event ListingCreated(address indexed token, uint256 indexed tokenId, address seller, uint256 price);
event ListingCancelled(address indexed token, uint256 indexed tokenId, address seller);
event OrderFilled(address indexed token, uint256 indexed tokenId, address buyer, address seller, uint256 price);
// ---- 上架:卖家需已授权给本合约 ----
function createListing(address tokenContract, uint256 tokenId,
uint256 price, uint256 deadline) external {
require(price > 0, "Price must be > 0");
require(block.timestamp < deadline, "Bad deadline");
// 校验卖家确实是 owner(721)或至少拥有该资产
IERC721 nft = IERC721(tokenContract);
require(nft.ownerOf(tokenId) == msg.sender, "Not owner");
require(nft.getApproved(tokenId) == address(this)
|| nft.isApprovedForAll(msg.sender, address(this)), "Not approved");
listings[tokenContract][tokenId] = Listing(msg.sender, price, deadline, true);
emit ListingCreated(tokenContract, tokenId, msg.sender, price);
}
// ---- 购买:CEI 顺序 ----
function purchase(address tokenContract, uint256 tokenId)
external payable nonReentrant
{
Listing memory l = listings[tokenContract][tokenId];
require(l.active, "Not listed");
require(block.timestamp <= l.deadline, "Expired");
require(msg.value >= l.price, "Insufficient funds");
// Effect:先移除挂单,防重入双花
listings[tokenContract][tokenId].active = false;
// Interaction:转 NFT(safeTransferFrom 会回调,故须在上锁内)
IERC721(tokenContract).safeTransferFrom(l.seller, msg.sender, tokenId);
// 结算资金(协议费 + 版税 + 卖家)
_settlePayment(tokenContract, tokenId, l.price, l.seller);
// 退还多付的 ETH
if (msg.value > l.price) _transferEth(msg.sender, msg.value - l.price);
emit OrderFilled(tokenContract, tokenId, msg.sender, l.seller, l.price);
}
function _transferEth(address to, uint256 amount) internal {
(bool ok, ) = to.call{value: amount}("");
require(ok, "ETH transfer failed");
}
}
一句话:上架=状态写入,购买=“移除挂单→转移 NFT→结算资金→退还多余”,严格遵循 Checks-Effects-Interactions。
五、场外撮合与签名订单(EIP-712)
为什么需要链下签名
链上挂单每笔都付 gas,且价格暴露给抢单者。链下签名订单让 Maker 免费挂单,Taker 提交时只付一次 gas:
Maker(卖方)链下签名订单:
{ token, tokenId, price, deadline, nonce }
+ EIP-712 domain separator 签名
Taker(买方)链上调用 fillOrder(order, signature):
校验签名 → 校验 nonce 未用 → 结算
EIP-712 结构化签名
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract SignedOrderMarketplace {
// EIP-712 域分隔符
bytes32 public immutable DOMAIN_SEPARATOR;
bytes32 internal constant ORDER_TYPEHASH =
keccak256("Order(address token,uint256 tokenId,uint256 price,uint256 deadline,uint256 nonce)");
mapping(address => uint256) public nonces;
mapping(bytes32 => bool) public filled;
constructor() {
DOMAIN_SEPARATOR = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("SignedOrderMarketplace"),
keccak256("1"),
block.chainid,
address(this)
));
}
function _hashOrder(Order calldata o) internal view returns (bytes32) {
return keccak256(abi.encodePacked(
"\x19\x01", DOMAIN_SEPARATOR,
keccak256(abi.encode(ORDER_TYPEHASH,
o.token, o.tokenId, o.price, o.deadline, o.nonce))
));
}
struct Order {
address token;
uint256 tokenId;
uint256 price;
uint256 deadline;
uint256 nonce;
}
function fillOrder(Order calldata order, bytes calldata signature) external nonReentrant {
bytes32 digest = _hashOrder(order);
require(!filled[digest], "Order already filled");
require(block.timestamp <= order.deadline, "Order expired");
// ECDSA 恢复签名者
address signer = ecrecover(digest, /* v,r,s */ ...);
require(signer == order.seller, "Bad signature");
filled[digest] = true;
nonces[signer] = order.nonce + 1;
// ... 结算
}
}
Seaport 的增强模型
OpenSea Seaport 用 Offer / Consideration 模型实现任意资产互换:
{
"offer": [{ "itemType": 2, "token": "0x...", "identifier": 123, "amount": 1 }],
"consideration": [
{ "itemType": 0, "token": "0x0", "identifier": 0, "amount": 1000000000000000000 },
{ "itemType": 3, "token": "0xRoyalty", "identifier": 0, "amount": 50000000000000000 }
],
"startTime": 1700000000, "endTime": 1730000000,
"signature": "0x..."
}
- 支持 ERC20/ETH/NFT 混合支付
- 支持部分撮合(partial fills)与高级订单组合
- 链下签名 + 链上结算,撮合成本极低
一句话:签名订单把"挂单"变成免费操作,EIP-712 保证签名与链、合约强绑定,nonce 防止重放。
六、安全考量:重入、抢单与资金卡死
攻击面清单
| 风险 | 场景 | 防御 |
|---|---|---|
| 重入攻击 | safeTransferFrom 回调恶意合约 | CEI + nonReentrant |
| 抢单/三明治 | 监控 pending 挂单低价买走 | 链下签名 + 最短结算路径 |
| 挂单价格过期 | 地板价波动后仍按旧价成交 | deadline + 价格区间 |
| 授权过期 | 卖家撤回授权后挂单仍生效 | 校验时实时查 isApprovedForAll |
| ERC-1155 数量 | 挂单数量超过实际持有 | 结算前 balanceOf 校验 |
| 收款地址为合约 | 卖家是恶意合约或无法收款 | 用 call + 失败回滚 |
| fee-on-transfer | 转账代币有内置扣费 | 使用 balanceAfter - balanceBefore 差值 |
| 版税绕过 | 场外转账或协商版税 | 协议级硬版税(如有) |
加固代码示例
// 抢单缓解:为卖方提供"更低价格窗口"
function purchaseWithLimit(address tokenContract, uint256 tokenId,
uint256 maxPrice) external payable nonReentrant {
Listing memory l = listings[tokenContract][tokenId];
require(l.active, "Not listed");
require(l.price <= maxPrice, "Price moved"); // 防止地板价暴跌后被割
// ... 后续同 purchase
}
// 防 ERC-1155 数量耗尽
function purchaseBatch1155(address tokenContract, uint256 id, uint256 amount, uint256 price)
external payable nonReentrant
{
require(IERC1155(tokenContract).balanceOf(msg.sender, id) >= amount, "Not enough");
// ...
}
一句话:NFT 市场 90% 的安全事故来自重入、过期授权与签名重放——把这三道关守住,就守住了资金安全。
七、去中心化交易所思路
链上订单簿 vs 链下订单簿
| 维度 | 链上订单簿 | 链下签名订单簿(DEX 思路) |
|---|---|---|
| 挂单成本 | 高(每单 gas) | 零(免费) |
| 撮合透明度 | 链上可查 | 签名公开可查 |
| 抢单暴露 | 高 | 低(最后提交) |
| 批量撮合 | 难 | 支持(聚合器) |
| 代表 | LooksRare(早期) | Seaport、Blur、LooksRare v2 |
扩展方向
- 聚合器:跨市场聚合最优价(Gem、OpenSea 聚合)
- 批量结算:一次交易撮合多个订单(Blur 的 blob 批量)
- 跨链市场:链上消息 + 托管(如 via CCIP/LayerZero)
- 混合 AMM + 订单簿:长尾资产用 AMM 提供即时流动性
# 部署 + 交互示例
forge create src/Marketplace.sol:Marketplace --rpc-url $RPC --private-key $KEY
cast send <MARKETPLACE> "createListing(address,uint256,uint256,uint256)" \
$NFT 1 1ether 1730000000 --private-key $SELLER_KEY
cast send <MARKETPLACE> "purchase(address,uint256)" $NFT 1 \
--value 1ether --private-key $BUYER_KEY
总结
| 维度 | 要点 |
|---|---|
| 标准选择 | ERC-721 表达独一无二,ERC-1155 表达集合与批量 |
| 架构 | 挂单模块 + 撮合模块 + 结算模块,职责分离 |
| 版税 | EIP-2981 royaltyInfo 统一查询,市场决定是否强制 |
| 流程 | 上架=写入,购买=移除挂单→转 NFT→结算→退款 |
| 签名订单 | EIP-712 防重放、免费挂单、低抢单暴露 |
| 安全 | CEI、nonce、deadline、实时授权校验、重入锁 |
| 演进 | Seaport 式 Offer/Consideration,聚合器与批量结算 |
NFT 市场是"标准协议 + 交易机制 + 资金安全"三者的交汇。构建时,先把 ERC-721/1155 的接口差异吃透,再设计清晰的结算管线,最后把重入、抢单、签名重放三道防线做扎实。值得借鉴 Seaport 的 Offer/Consideration 通用模型:它把任意资产互换抽象为统一的订单结构,让市场合约不仅能卖 NFT,还能做任意代币撮合——这正是去中心化交易基础设施的核心竞争力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。