WTF-Solidity 第38讲:用智能合约搭建零手续费的去中心化 NFT 交易所 NFTSwap
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
本篇技术指南完整讲解 WTF-Solidity 教程第 38 讲的核心内容:如何在以太坊上用 Solidity 智能合约实现一个去中心化、零手续费的 NFT 交易所NFTSwap。全文以 Languages/pt-br/38_NFTSwap/readme.md 为骨架,结合仓库内的 NFTSwap.sol 源码、ERC721 系列合约 与 WTFApe 示例 NFT,从设计逻辑、合约实现、底层原理到 Remix 全流程实操逐一展开。读完后你将掌握:如何用事件(Event)、结构体(Struct)、映射(Mapping)与 ERC721 安全转账机制构建一个完整的链上订单簿式 NFT 交易合约,并能在 Remix 中亲手完成部署、授权、挂单、改价、撤单与购买。
背景:为什么需要去中心化 NFT 交易所
原教程以OpenSea为例引出问题:OpenSea是以太坊上最大的 NFT 交易平台,总交易量超过 300 亿美元,且对每笔交易抽成 2.5%,其运作并非去中心化,用户承担了高昂的交易成本。原教程据此提出用智能合约搭建一个零手续费的去中心化 NFT 交易所NFTSwap,让挂单、撤单、改价、购买全部在链上公开执行,无需信任任何平台方。
说明:上述市场规模与费率数据来自原教程的背景叙述,用于引出"零手续费去中心化交易所"的设计动机;本文后续所有技术结论均以仓库中的合约源码为直接依据。
设计逻辑
NFTSwap是一个典型的**链上订单簿(On-chain Order Book)**模型,只有三种参与角色:
- 卖家:出售 NFT 的一方,可以挂单
list、撤单revoke、修改价格update。 - 买家:购买 NFT 的一方,可以调用
purchase完成购买。 - 订单(Order):卖家发布的 NFT 链上订单。一个系列的同一
tokenId最多存在一个订单,订单中记录挂单价格price和持有人owner信息;订单成交或被撤单后,其中的信息被清零。
该设计直接对应合约中的四个行为:list/revoke/update/purchase,合约也用四个事件分别记录这四个行为。
NFTSwap合约源码解析
合约完整源码位于 Languages/pt-br/38_NFTSwap/NFTSwap.sol(根目录下另有同名的 38_NFTSwap/NFTSwap.sol),编译版本为pragma solidity ^0.8.34。它继承了IERC721Receiver接口,以便通过 ERC721 的安全转账机制接收 NFT。
1. 事件:四个行为一一对应
合约包含 4 个事件,参数均使用indexed关键字标注可检索字段,方便链下索引与前端监听:
event List( address indexed seller, address indexed nftAddr, uint256 indexed tokenId, uint256 price ); event Purchase( address indexed buyer, address indexed nftAddr, uint256 indexed tokenId, uint256 price ); event Revoke( address indexed seller, address indexed nftAddr, uint256 indexed tokenId ); event Update( address indexed seller, address indexed nftAddr, uint256 indexed tokenId, uint256 newPrice );List:挂单成功时触发,记录卖家、NFT 合约地址、tokenId与挂单价格;Purchase:购买成交时触发,记录买家、NFT 合约地址、tokenId与成交价;Revoke:撤单成功时触发,记录卖家、NFT 合约地址与tokenId;Update:改价成功时触发,记录卖家、NFT 合约地址、tokenId与新的挂单价格newPrice。
事件是 Solidity 中合约与链下世界通信的主要通道,indexed参数可被日志高效检索,这正是交易所需要"上架/成交"通知能力的基础。
2. 订单:Order结构体 + 双重nftList映射
NFT 订单被抽象为一个Order结构体,只包含两个字段——持有人owner与挂单价格price:
struct Order { address owner; uint256 price; } // NFT Order 映射:NFT合约地址 => tokenId => 订单 mapping(address => mapping(uint256 => Order)) public nftList;nftList是一个双重映射:第一层键是 NFT 合约地址(address),第二层键是tokenId(uint256),最终定位到该 NFT 的订单。由于映射天然以键唯一,同一tokenId最多只存在一个订单的设计由数据结构直接保证。映射被声明为public,Solidity 会自动生成对应的 getter 函数,前端可以直接查询任意 NFT 的订单状态(见下文 Remix 实操第 5 步)。
当订单成交(purchase)或撤单(revoke)时,源码使用delete nftList[_nftAddr][_tokenId]将订单信息整体清零(owner归零地址、price归 0),符合"订单信息清零"的设计约定。
3. 回退函数:接收 ETH
在NFTSwap中,用户使用ETH购买 NFT,因此合约需要实现接收 ETH 的能力:
fallback() external payable {}fallback()被声明为payable,保证任何没有匹配到现有函数的 ETH 转账都能被合约接收(不会被 revert 拒绝)。关于 ETH 转账的三种方式(transfer/send/call),可参考仓库 20_SendETH 一讲的讲解。
4.onERC721Received:让合约能"安全地"接收 NFT
ERC721 的安全转账(safeTransferFrom)会在转账时检查接收方:如果接收方是合约,则必须实现onERC721Received()函数并返回正确的选择器selector,否则转账会被拒绝,从而防止 NFT 被永久锁死在无法转出的合约里。
用户挂单后需要把 NFT 发送到NFTSwap合约托管,所以NFTSwap继承IERC721Receiver接口并实现该函数:
contract NFTSwap is IERC721Receiver { // 实现 IERC721Receiver.onERC721Received,使合约能够接收 ERC721 代币 function onERC721Received( address, address, uint256, bytes calldata ) external pure override returns (bytes4) { return IERC721Receiver.onERC721Received.selector; } }接口定义位于 34_ERC721/IERC721Receiver.sol。只要返回0x150b7a02(即IERC721Receiver.onERC721Received.selector),就表示合约明确知晓并接受 ERC721 协议的接收约定。
5. 四个交易函数
合约实现了 4 个交易相关函数,覆盖完整的买卖生命周期。以下代码为仓库最新源码(与原文档教学代码逻辑一致,并在此基础上增加了若干安全校验,差异点会在小节末尾说明)。
挂单list()
卖家上架 NFT 并创建订单,成功后 NFT 从卖家转入NFTSwap合约托管:
function list(address _nftAddr, uint256 _tokenId, uint256 _price) public { require(_price > 0, "Price must be greater than 0"); IERC721 _nft = IERC721(_nftAddr); require( _nft.ownerOf(_tokenId) == msg.sender, "Only the owner can list the NFT" ); require( _nft.getApproved(_tokenId) == address(this), "Contract is not approved to transfer this NFT" ); nftList[_nftAddr][_tokenId] = Order(msg.sender, _price); _nft.safeTransferFrom(msg.sender, address(this), _tokenId); emit List(msg.sender, _nftAddr, _tokenId, _price); }参数说明:
_nftAddr:NFT 合约地址;_tokenId:NFT 对应的tokenId;_price:挂单价格,单位是wei(1 ether = 10^18 wei)。
核心校验与动作:
_price > 0:价格必须大于 0;_nft.ownerOf(_tokenId) == msg.sender:只有 NFT 实际持有人才能挂单(该检查为最新源码新增);_nft.getApproved(_tokenId) == address(this):卖家必须先调用 NFT 合约的approve()把该tokenId授权给NFTSwap合约,合约才能调用safeTransferFrom完成托管;safeTransferFrom(msg.sender, address(this), _tokenId):将 NFT 安全转入合约;- 释放
List事件。
撤单revoke()
卖家撤销挂单,NFT 从合约转回卖家,订单信息被清零:
function revoke(address _nftAddr, uint256 _tokenId) public { Order memory order = nftList[_nftAddr][_tokenId]; require(order.owner == msg.sender, "You are not the owner of this NFT"); IERC721 _nft = IERC721(_nftAddr); require( _nft.ownerOf(_tokenId) == address(this), "NFT is not in the contract" ); delete nftList[_nftAddr][_tokenId]; _nft.safeTransferFrom(address(this), msg.sender, _tokenId); emit Revoke(msg.sender, _nftAddr, _tokenId); }核心校验与动作:
order.owner == msg.sender:只有挂单的卖家本人可以撤单;_nft.ownerOf(_tokenId) == address(this):确认 NFT 确实托管在合约中(订单有效);- 先
delete订单(Effects),再执行转账(Interactions); - 释放
Revoke事件。
修改价格update()
卖家修改挂单价格,只更新price字段,不转移 NFT:
function update( address _nftAddr, uint256 _tokenId, uint256 _newPrice ) public { require(_newPrice > 0, "Price must be greater than 0"); Order storage order = nftList[_nftAddr][_tokenId]; require(order.owner == msg.sender, "You are not the owner of this NFT"); order.price = _newPrice; emit Update(msg.sender, _nftAddr, _tokenId, _newPrice); }参数_newPrice为新的挂单价格(单位wei)。注意这里使用了storage引用直接改写链上订单的price字段,比重新写入整个结构体更省 gas。
购买purchase()
买家支付ETH购买 NFT,成功后 ETH 转给卖家、NFT 转给买家、多余 ETH 退回买家、订单清零:
function purchase(address _nftAddr, uint256 _tokenId) public payable { Order memory order = nftList[_nftAddr][_tokenId]; require(order.price > 0, "NFT is not listed for sale"); require(msg.value >= order.price, "Insufficient ETH to purchase NFT"); IERC721 _nft = IERC721(_nftAddr); require( _nft.ownerOf(_tokenId) == address(this), "NFT is not in the contract" ); delete nftList[_nftAddr][_tokenId]; _nft.safeTransferFrom(address(this), msg.sender, _tokenId); payable(order.owner).transfer(order.price); if (msg.value > order.price) { payable(msg.sender).transfer(msg.value - order.price); } emit Purchase(msg.sender, _nftAddr, _tokenId, order.price); }核心校验与动作:
order.price > 0:确认该 NFT 确实处于挂单状态;msg.value >= order.price:买家支付金额不能低于挂单价;_nft.ownerOf(_tokenId) == address(this):确认 NFT 托管在合约中;- 先
delete订单(Effects),再完成 NFT 转账与 ETH 结算(Interactions); payable(_order.owner).transfer(_order.price):把挂单价转给卖家;if (msg.value > order.price):如果买家多付了,把差额原路退回;- 释放
Purchase事件,price参数取订单中的挂单价(而非买家实际支付额)。
教学代码与最新源码的差异
原文档中的教学代码与仓库最新源码逻辑一致,但最新源码 Languages/pt-br/38_NFTSwap/NFTSwap.sol 做了几处值得注意的加强:
list()新增_nft.ownerOf(_tokenId) == msg.sender校验,从源头杜绝非持有人冒名挂单;purchase()与revoke()均采用Order memory order拷贝读取订单,并先删除订单、再执行转账(即 Checks-Effects-Interactions 顺序),从源码结构看可显著降低重入攻击风险;purchase()的退款逻辑改为if (msg.value > order.price)条件判断,仅在存在差额时才执行退款;- 更新后的源码在
purchase()使用transfer()发送 ETH(固定 2300 gas),可防止收款合约通过回调消耗过多 gas 进行重入。
底层原理:ERC721 授权与安全转账机制
NFTSwap能运作的前提,是 ERC721 标准的**授权(Approve)与安全转账(Safe Transfer)**机制,其实现位于 34_ERC721/ERC721.sol:
- 授权查询:
getApproved(tokenId)返回指定tokenId当前的授权地址(ERC721.sol 中通过_tokenApprovals映射实现)。NFTSwap.list()正是靠它校验"合约是否已获得该 NFT 的转移授权"。 - 授权函数:
approve(to, tokenId)仅允许 NFT 持有人或被setApprovalForAll批量授权的地址调用(ERC721.sol),这与实操中"卖家先 approve、再 list"的顺序完全吻合。 - 安全转账:
safeTransferFrom最终调用私有函数_safeTransfer,内部先执行_transfer完成余额与所有权变更,再调用_checkOnERC721Received检查接收方(ERC721.sol)。若接收方是合约且未实现onERC721Received,转账会被 revert,这正是NFTSwap必须实现onERC721Received()的原因。
Remix 全流程实操
下面按原教程步骤,在 Remix IDE 的 JavaScript VM(测试环境)中完整跑通 NFTSwap 的九步流程。测试 NFT 合约使用 34_ERC721/WTFApe.sol(一个继承自 ERC721.sol 的简单 NFT,总量MAX_APES = 10000,构造函数传入名称与代号,mint可铸造任意tokenId)。
1. 部署 NFT 合约WTFApe
先在 Remix 中编译并部署WTFApe,构造参数NAME、SYMBOL均填WTF:
随后调用mint给自己铸造两个 NFT(tokenId分别为0和1),并调用ownerOf(0)确认tokenId = 0的 NFT 持有人是自己。NFT 的铸造与所有权逻辑详见 34_ERC721 一讲。
2. 部署NFTSwap合约
编译并部署 NFTSwap.sol,部署时无需构造参数(合约无构造函数入参)。
3. 授权 NFT 给NFTSwap合约
在WTFApe合约中调用approve(address to, uint256 tokenId):
to:授权目标地址,填NFTSwap合约地址;tokenId:要授权挂单的 NFT 编号,这里为0。
依次为tokenId = 0和tokenId = 1完成授权。只有先授权,NFTSwap.list()中的getApproved检查才能通过。
4. 挂单list
调用NFTSwap的list(address _nftAddr, uint256 _tokenId, uint256 _price):
_nftAddr:WTFApe合约地址;_tokenId:要挂单的 NFT 编号,这里为0;_price:挂单价格,此处为1 wei。
挂单成功后 NFT 会从卖家地址转入NFTSwap合约。重复一次,以1 wei的价格把tokenId = 1也挂上。
5. 查看挂单
调用公开映射nftList的自动 getter:传入_nftAddr与_tokenId,即可查到该 NFT 的订单,返回owner(挂单卖家地址)与price(挂单价格):
返回结果显示owner为卖家地址、price为 1,说明挂单成功且价格正确。
6. 修改价格update
调用update(address _nftAddr, uint256 _tokenId, uint256 _newPrice),把tokenId = 0的价格改为77 wei:
_nftAddr:WTFApe合约地址;_tokenId:0;_newPrice:77(单位 wei)。
改价后再查nftList,price已变为77,说明更新生效。改价只更新链上订单字段,无需再次转移 NFT。
7. 撤单revoke
调用revoke(address _nftAddr, uint256 _tokenId)撤下tokenId = 1的挂单:
_nftAddr:WTFApe合约地址;_tokenId:1。
再次查询nftList,owner归零地址、price归 0,说明订单已被删除,NFT 已退回卖家。注意:撤单后若要重新挂单,必须重新走"授权 + 挂单"两步。
8. 购买purchase
切换 Remix 账户(用另一个地址充当买家),调用purchase(address _nftAddr, uint256 _tokenId),并在交易面板的Value中填入要支付的 ETH 数量:
_nftAddr:WTFApe合约地址;_tokenId:0(前面已把tokenId = 1撤单,这里买0号);- 支付金额:
77 wei(须大于等于挂单价)。
成交后:NFT 从合约转入买家,77 wei转给卖家,订单被删除。若支付金额超过挂单价,差额会自动退回买家。
9. 验证所有权变更
在WTFApe合约中调用ownerOf(0),确认tokenId = 0的持有人已变为买家地址,即购买成功:
至此,一笔完整的去中心化 NFT 交易(挂单 → 改价 → 撤单 → 购买)在链上全部完成,全程没有平台抽成,也不依赖任何中心化撮合服务器。
安全与设计要点小结
结合合约源码可以总结出几个关键设计要点(部分为基于代码结构的推断,供读者结合文档批判性阅读):
- 托管式挂单:挂单即把 NFT 锁定在交易所合约中,杜绝了"一物多挂""挂单后私下转移"等链下撮合模式常见的欺诈风险,成交与撤单都必须由合约统一执行;
- CEI 顺序:
purchase与revoke均先删除订单、再执行外部转账,配合transfer()的 2300 gas 限制,从源码结构看可有效降低重入攻击面; - 单边锁定:同一
tokenId最多一个订单,由mapping键唯一性天然保证; - 已知局限:作为教学合约,它没有实现版税(Royalty)、批量挂单、出价(Offer)等商业交易所功能;
transfer()在部分新链上可能因 gas 语义差异表现不同,生产级实现通常会改用call配合重入防护(如 OpenZeppelin 的ReentrancyGuard)。另外,update()在最新源码中不再校验 NFT 是否仍托管在合约内,从代码结构看,只要订单存在且owner匹配,NFT 一般应处于托管状态,但读者应在二次开发时自行评估该取舍。
总结
本讲用不到 200 行代码实现了一个功能完整的去中心化 NFT 交易所NFTSwap:通过Order结构体 + 双重nftList映射构建链上订单簿,通过四个事件对外广播交易行为,通过onERC721Received+safeTransferFrom安全托管 NFT,通过fallback接收买家支付的 ETH。它证明了"挂单、改价、撤单、购买"这套传统交易所的核心能力,完全可以用智能合约以零手续费、全公开、无托管风险的方式在链上实现。原教程在结论中展望:随着Looksrare、dydx等新平台和Uniswap等项目的探索,NFT 交易基础设施正在向更开放、更公平的方向演进——而理解NFTSwap的源码,正是理解这一切的起点。
对 ERC721 标准本身还不熟悉的读者,建议先阅读仓库 34_ERC721 一讲的 readme.md;对事件、映射、结构体等 Solidity 基础语法,可回看 07_Mapping 与 12_Event 两讲。
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考