NFT 市场合约开发:ERC-721/1155 深入与版税

深入 NFT 市场合约开发:ERC-721/1155 标准差异、挂单/撮合/托管架构、EIP-2981 版税实现、上架购买流程、EIP-712 签名订单与重入抢单等安全考量。

NFT 市场是去中心化交易基础设施的代表。一个安全、可组合的市场合约需要同时处理好代币标准、订单撮合、资金托管、版税分发与抗抢单。OpenSea(Seaport)、Blur、LooksRare 的交易量证明了这一赛道的重要性。本文将从标准差异讲到生产级市场合约的实现。

一、NFT 标准对比:ERC-721 vs ERC-1155

标准速览

维度ERC-721ERC-1155
同质性非同质(1 id = 1 资产)半同质(id 可同质或非同质)
批量操作无原生批量safeBatchTransferFrom
元数据tokenURI(tokenId)uri(id) 支持模板
余额balanceOf(owner)balanceOf(owner, id)
授权approve + setApprovalForAllsetApprovalForAll(批量)
应用PFP、加密艺术游戏道具、可组合资产
通用OpenZeppelin ERC721OpenZeppelin 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 / ERC20ETH 简单;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,还能做任意代币撮合——这正是去中心化交易基础设施的核心竞争力。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「区块链 Web3」更多文章

  1. 非 EVM 生态:Solana 与 Move 系公链开发
  2. MEV 与区块构建市场:抢跑、三明治与 PBS
  3. Solidity Gas 优化:存储布局、数据位置与代理模式