Web3 新手系列:从零实现一个 NFT DEX
看过前面几篇文章的用户应该知道,对于 ERC-20 协议的代币,我们可以通过以 Uniswap 为代表的 DEX 进行交易,做到去中心化。那么对于 ERC-721 协议,也就是 NFT 来说,如何做到去中心化交易呢。
目前主流的一些 NFT 交易所,采用的是挂单的方式进行交易,就像是把一件件商品列到超市的货架上一样,购买者觉得价格合适,就可以把商品带回家。
本文将通过编写智能合约和一个简单的前端页面,实现 NFT 的去中心化交易。注意本文只供学习使用,并不能真实用在生产环境。
NFT(Non-Fungible Token)
NFT 也就是非同质化代币,即每一个 Token 都是非同质的,不一样的,它遵循 ERC-721 协议。一般来说每一个 NFT 在钱包里面会展示不一样的图片,并且每一组 NFT 都会有一个独一无二的 ID 来区分。
由于 NFT 的特性,它没有办法和 ERC-20 一样通过价格曲线来设定价格——因为每一个 Token 都是不一样的。所以目前比较常见的交易方式是通过订单簿的形式。
订单簿交易
订单簿模式简单来说就是商品的价格是人为设定的,有别于 Uniswap 这种通过价格曲线计算价格的方式。订单簿一般来说会分为两种交易模式,一种是定价单,即卖家设定一个自己心里的出售价格,如果有买家觉得价格合适,就可以由买家进行购买。另一种是求购单,即买家根据自己的需求,发出一笔求购订单,当卖家觉得价格合适时,就可以由卖家进行出售。
一般来说,求购单的价格会低于定价单的价格。本文只介绍第一种定价方式。
NFT DEX 的功能
一个 NFT DEX 的基础功能应该包含以下基本的功能:
- 上架商品:将一个 NFT 按照定价进行上架
- 购买商品:根据 NFT 的定价进行购买
- DEX 手续费:根据成交的价格按比例收取手续费
上架商品
上架商品需要做以下几件事情:
- 前端:用户选择自己的 NFT,并且设定一个价格,点击上架
- 合约:用户需要给合约设置权限,可以操控用户的 NFT
这样商品就算上架完了。在合约中,需要维护一份用户的上架商品价格 Map,这部分数据一般来说是可以做到中心化的服务中,以减少合约的负担,但是在本文中这部分 Map 数据会维护在合约里面。
购买商品
购买商品的时候会发生一下几件事情:
- 前端:用户选择一个想要购买的 NFT,点击购买
- 合约:调用合约,将用户的钱转到 NFT 的卖方,并将 NFT 转到买方。
实现一个 NFT DEX
在本章节,我们将会从零开始实现一个 NFT 的 DEX,这是笔者已经部署好的 DEX 地址 nft-dex-frontend.vercel.app。
1. 创建一个 NFT
为了测试需要,我们最好是能够有一个自己的 NFT。我们可以通过 Remix 快速搭建一个 ERC-721 协议的 NFT,它提供了对应的模板。
我们按照模板可以方便地部署一个 NFT。当然你也可以跳过这一个步骤,直接使用我们准备好的 NFT。
2. 合约编写
我们的合约方法应该包含一下几个方法:
2.1. 卖家上架 NFT
卖家需要指定要售卖的 NFT 以及对应的价格。在上架时,用户需要签署 NFT 的授权方法,让我们的智能合约有权限操作这个 NFT,这样当有买家购买之后,这笔交易可以自动成交。
所以流程应该是这样的:1. 用户选择自己的 NFT;2. 设置价格,这里的计价可以是稳定币 USDT、USDC,也可以是 ETH;3. 授权 NFT 给到合约。
之后就可以调用合约的上架方法了,该方法需要做以下几件事情:
- 对 NFT 的所有权进行校验
- 添加上架记录
- 触发上架的事件
/**
* @dev 上架 NFT
* @param _nftContract NFT合约地址
* @param _tokenId NFT代币ID
* @param _price 价格
* @param _paymentToken 支付代币(0表示ETH)
*/
function listNFT(
address _nftContract,
uint256 _tokenId,
uint256 _price,
address _paymentToken
) external {
require(_nftContract != address(0), "NFTDEX: NFT合约地址不能为0");
require(_price > 0, "NFTDEX: 价格必须大于0");
// 确保用户已经给交易所合约授权转移NFT
IERC721 nft = IERC721(_nftContract);
require(nft.ownerOf(_tokenId) == msg.sender, "NFTDEX: 你不是该NFT的所有者");
require(
nft.getApproved(_tokenId) == address(this) ||
nft.isApprovedForAll(msg.sender, address(this)),
"NFTDEX: 请先授权NFT给交易所"
);
// 创建上架记录
uint256 listingId = currentListingId;
listings[listingId] = Listing({
seller: msg.sender,
nftContract: _nftContract,
tokenId: _tokenId,
price: _price,
paymentToken: _paymentToken,
isActive: true
});
// 添加到卖家的上架列表
sellerListings[msg.sender].push(listingId);
// 增加上架ID
currentListingId++;
// 触发上架事件
emit NFTListed(listingId, msg.sender, _nftContract, _tokenId, _price, _paymentToken);
}
2.2. 买家购买 NFT
买家在购买 NFT 的时候,用户只需要选择自己想要的 NFT,并支付相应的代币即可。合约层面会执行以下几个步骤:1. 从 listings
中读取到对应的 NFT 数据;2. 根据 NFT 的价格,计算手续费,并从成交价中扣除这部分;3. 转移 NFT 到买家手中;4. 触发购买的事件
function purchaseNFT(uint256 _listingId) external payable {
Listing storage listing = listings[_listingId];
require(listing.isActive, "NFTDEX: 该NFT未上架或已售出");
require(listing.seller != msg.sender, "NFTDEX: 不能购买自己的NFT");
address seller = listing.seller;
address nftContract = listing.nftContract;
uint256 tokenId = listing.tokenId;
uint256 price = listing.price;
address paymentToken = listing.paymentToken;
// 计算平台费
uint256 fee = (price * feeRate) / 10000;
uint256 sellerAmount = price - fee;
// 处理支付
if (paymentToken == address(0)) {
// 使用ETH支付
require(msg.value == price, "NFTDEX: 支付金额不正确");
// 转账ETH给卖家
(bool success, ) = payable(seller).call{value: sellerAmount}("");
require(success, "NFTDEX: 转账ETH给卖家失败");
// 平台费留在合约中
} else {
// 使用ERC20代币支付
IERC20 token = IERC20(paymentToken);
// 转账代币给卖家
require(token.transferFrom(msg.sender, seller, sellerAmount), "NFTDEX: 转账代币给卖家失败");
// 转账平台费给合约拥有者
require(token.transferFrom(msg.sender, owner, fee), "NFTDEX: 转账平台费失败");
}
// 转移NFT
IERC721(nftContract).safeTransferFrom(seller, msg.sender, tokenId);
// 更新上架状态
listing.isActive = false;
// 触发购买事件
emit NFTPurchased(_listingId, msg.sender, seller, nftContract, tokenId, price, paymentToken);
}
2.3. 取消上架
当然,卖家可能会觉得价格不合适,会选择取消上架。可以看到我们在保存上架信息的地方,保留了一个 isActive 的字段,用于表明该商品是否有效,因此在取消上架的时候,我们只需要将这个字段设置为 false 即可。
function cancelListing(uint256 _listingId) external onlySeller(_listingId) {
listings[_listingId].isActive = false;
emit ListingCanceled(_listingId);
}
2.4. 提取手续费
DEX 可以在每一笔的交易中收取手续费,这个手续费即可以存到合约里,也可以转存到另一个你自己的地址中去,本文采取存到合约里的方式。
function withdrawFees() external onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "NFTDEX: 没有可提取的费用");
(bool success, ) = payable(owner).call{value: balance}("");
require(success, "NFTDEX: 提取费用失败");
}
到此为止,我们的合约基本功能就算完整了。
3. DEX 前端开发
在开始之前,我们需要准备一些工具,包含如下几个工具:
- Ant Design Web3:用于钱包的连接以及 NFT 卡片的展示
- Wagmi:用于和钱包进行交互
- Nextjs + Vercel:部署我们的项目
我们的前端应用应该包含三个页面,Mint、Buy 以及 Portfolio,Mint 是为了让用户能够 Mint 我们的 NFT,仅仅用于演示,Buy 的话是我们的 DEX 商城,用户可以在里面购买我们的 NFT,Portfolio 里面用户可以对 NFT 进行上架和下架操作。
3.1. 连接钱包
连接用户的钱包,使用 Ant Design Web3 实现
连接用户的钱包的过程非常简单,使用 Ant Design Web3 提供的连接组件即可。
首先我们在项目的外层包一个 Provider, 这样在后续的代码里面我们就能用到 Ant Design Web3 的能力。另外由于我们需要连接 sepolia 测试链,为了速度考虑,建议使用一些节点服务来提高数据查询的速度,我这里使用的是 ZAN 的 endpoint,它非常适合在亚太环境下使用,速度快并且价格非常划算,支持的链也很丰富。
<WagmiWeb3ConfigProvider
chains={[Sepolia]}
transports={{
[Sepolia.id]: http(zan.endpoint),
}}
walletConnect={{
projectId: 'YOUR ID',
}}
eip6963={{
autoAddInjectedWallets: true,
}}
wallets={[
MetaMask(),
OkxWallet(),
]}
queryClient={queryClient}
>
{children}
</WagmiWeb3ConfigProvider>
之后在需要连接钱包的地方放置一个连接按钮:
{/* 连接钱包按钮 */}
<Connector
modalProps={{
mode: 'simple',
}}
>
<ConnectButton />
</Connector>
这样就算是搞定了,非常的简单。
3.2. Mint
Mint 一个 NFT,获得测试代币可以前往 https://zan.top/faucet/ethereum
在 Mint 页面我们可以 Mint 测试用的 NFT。Mint 是一个写合约的操作,这里我们要用到 wagmi 里面的 useWriteContract 方法。我们需要指定好合约地址、合约的 ABI 以及合约参数即可。
const { writeContractAsync} = useWriteContract();
writeContractAsync({
address: '0x07d3b2cee477291203cd8f1928ee231d583cb908',
abi: abi,
functionName: "safeMint",
args: [address],
}
之后在钱包里面进行确认就可以 Mint 成功了。
3.3. Portfolio
管理用户的 NFT
在这里需要展示用户所有的 NFT。我们可以使用一些 NFT API 来获取,这里使用 opensea 的 API,因为支持 sepolia 测试链的 NFT API 并不多。
在获取到用户的 NFT 列表之后,需要判断是否已经是上架了的,未上架的支持上架,已上架的支持下架。判断的方式是通过 DEX 合约里面 getSellerListings
方法里面获取用户已经上架的 NFT,然后根据这些 NFT 的 isAlive
字段来判断是否正在上架。
const {data: sellerListings, refetch, isLoading} = useReadContract({
abi: abi,
address: dexAddress,
functionName: 'getSellerListings',
args: [account?.address],
query: {
enabled: !!account?.address,
}
})
const {data: tokensInfo, isLoading: tokensLoding} = useReadContracts({
contracts: (sellerListings as bigint[] || []).map((listingId) => ({
abi: abi as Abi, // Ensure abi is explicitly cast to the expected type
address: dexAddress as `0x${string}`,
functionName: 'listings',
args: [listingId],
})),
query: {
enabled: !!sellerListings && (sellerListings as bigint[])?.length > 0,
}
})
tokensInfo: tokensInfo?.map((item, index) => {
const result = item.result as any[];
return {
seller: result.at(0) as string,
contract: result.at(1) as string,
tokenId: result.at(2) as bigint,
price: result.at(3) as bigint,
payToken: result.at(4) as string,
isAlive: result.at(5) as boolean,
listId: (sellerListings as bigint[] || [])?.at(index),
}
})
上架的时候需要调用listNFT
合约方法,在取消的时候需要调用cancelListing
方法。在上架之前,需要额外调用 NFT 的授权方法,将 NFT 授权给合约,这样在后续交易成交之后,这个 NFT 就可以自动转给买方。
await writeContractAsync({
abi: nftAbi,
address: nftAddress,
functionName: 'approve',
args: [dexAddress, nfts[selectedNft].identifier],
})
3.4. Buy
在 Buy 里面可以购买 NFT
首先我们需要对已经上架的 NFT 进行展示。类似于 Portfolio 里面的展示用户已有的 NFT,这里不同点在于一个是全局的,不再是某个用户,另一个是只需要展示isAlive
的 NFT。
购买的时候使用purchaseNFT
方法,在调用这个方法的时候,需要用 ETH 来支付售价。
await writeContractAsync({
abi,
address: dexAddress,
functionName: 'purchaseNFT',
args: [tokenId],
value,
}
这里的这个value
就是买家需要支付的 ETH。
这样一个包含所有基础能力的 DEX 前端页面就完成了,我们可以将其部署在 vercel 中。
关于 ZAN
ZAN 是蚂蚁数科旗下 Web3 科技品牌,致力于 Web3 应用优化--降低成本、增强安全和提升性能,围绕 Web3 应用全生命周期,提供可靠、稳定安全、定制化的产品和服务。依托 AntChain OpenLabs 的 TrustBase 开源开放技术体系,ZAN 拥有 Web3 领域独特的优势和创新能力,为 Web3 社区的区块链应用开发、企业和开发者的 Web3 应用提供了全面的技术产品和服务,其中包括节点服务(ZAN Node Service)、zk 加速(ZAN PowerZebra)、身份验证eKYC(ZAN Identity)以及智能合约审计(ZAN Smart Contract Review)等。
联系我们
Website | X | Discord | Telegram