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)等。