NFT 自 2021 年熱潮以來,已成為 web3 生態系統的核心組成部分,其應用範疇早已超越收藏品,延伸至會員資格存取、資產代幣化、遊戲道具及數位身份等現實世界的使用場景。
Phosphor 等無程式碼平台透過直覺化的 Creator Studio 與 API 套件,讓創作者和開發者比以往更輕鬆地鑄造、自訂及部署 NFT,無需從頭建立自訂 NFT 合約。然而,對於希望深入理解 Solidity 與 web3 原生體驗的開發者而言,學習如何建構 NFT 市場仍是一項基礎技能。
本指南將帶您逐步完成在 Linea 上開發並部署一個基礎但功能完整的 NFT 市場的流程。完成後,您將具備啟動自己的市場所需的知識,並對智能合約開發、去中心化應用程式及 web3 互動有更深入的掌握。
前置條件
您應具備 Solidity 的基本知識。
您的電腦應已安裝 Nodejs 與 Foundry。
合約概覽
此 NFT 市場將支援以下功能:
上架 NFT 出售:使用者可設定價格並上架其 NFT,相關資訊安全儲存於智能合約中。
購買 NFT:買家可支付所需的 Ether 購買已上架的 NFT,確保交易流暢無阻。
批次購買 NFT:可在單筆交易中購買多個 NFT,以降低 gas 費用。
下架 NFT:擁有者可將其 NFT 從出售清單中移除,使其返回自己的錢包。
專案設定
本專案將使用 Foundry 框架。安裝 Foundry 後,執行以下指令以建立新專案:
forge init market在 Vscode 或您慣用的程式碼編輯器中開啟 market 資料夾,並刪除 scripts/counter.s.sol、src/counter.sol 及 test/counter.t.sol。
安裝所有相依套件
forge install foundry-rs/forge-std --no-commit && forge install OpenZeppelin/openzeppelin-contracts --no-commitNFT 市場智能合約完整程式碼
在 src 資料夾中建立 market.sol 檔案,並將以下程式碼加入該檔案。
pragma solidity ^0.8.18;
import "lib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sol";
import {Test, console} from "forge-std/Test.sol";
contract NFTMarketplace {
struct Listing {
address seller;
uint256 price;
}
mapping(address => mapping(uint256 => Listing)) public listings;
event Listed(address indexed nftContract, uint256 indexed tokenId, address seller, uint256 price);
event Purchase(address indexed nftContract, uint256 indexed tokenId, address buyer, uint256 price);
event Delisted(address indexed nftContract, uint256 indexed tokenId, address seller);
modifier isSeller(address nftContract, uint256 tokenId, address spender) {
require(listings[address(nftContract)][tokenId].seller == spender, "Not the seller");
_;
}
modifier isListed(address nftContract, uint256 tokenId) {
require(listings[nftContract][tokenId].price > 0, "Not listed");
_;
}
modifier notListed(address nftContract, uint256 tokenId) {
require(listings[nftContract][tokenId].price == 0, "Already listed");
_;
}
function listNFT(address nftContract, uint256 tokenId, uint256 price)
external
notListed(nftContract, tokenId)
{
require(price > 0, "Price must be greater than zero");
require(IERC721(nftContract).ownerOf(tokenId) == msg.sender, "not owner");
IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);
listings[nftContract][tokenId] = Listing(msg.sender, price);
emit Listed(nftContract, tokenId, msg.sender, price);
}
function buyNFT(address nftContract, uint256 tokenId)
public
payable
isListed(nftContract, tokenId)
{
Listing memory listing = listings[nftContract][tokenId];
require(msg.value >= listing.price, "Insufficient funds");
delete listings[nftContract][tokenId];
(bool sent, ) = listing.seller.call{value: listing.price}("");
require(sent, "Failed to send Ether");
IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);
emit Purchase(nftContract, tokenId, msg.sender, listing.price);
}
function buyMultipleNFT(address nftContract, uint256 [] memory tokenIds)
external
payable
{
uint256 totalAmount;
for (uint256 i; i < tokenIds.length; i++){
Listing memory listing = listings[nftContract][tokenIds[i]];
totalAmount += listing.price;
}
require(msg.value >= totalAmount, "Insufficient funds");
for (uint256 i; i < tokenIds.length; i++){
buyNFT(nftContract, tokenIds[i]);
}
}
function delistNFT(address nftContract, uint256 tokenId)
external
isListed(nftContract, tokenId)
isSeller(nftContract, tokenId, msg.sender)
{
Listing memory listing = listings[nftContract][tokenId];
delete listings[nftContract][tokenId];
IERC721(nftContract).transferFrom(address(this), listing.seller, tokenId);
emit Delisted(nftContract, tokenId, listing.seller);
}
}
程式碼主要功能
Listing 結構體
這對於在去中心化 NFT 市場中實現上架、購買及下架等功能至關重要。它提供了一種清晰且高效的方式來管理上架清單,同時確保市場保有每個項目的賣家與價格記錄——這兩者對市場運作都不可或缺。
struct Listing {
address seller;
uint256 price;
}
Listing 映射
這行程式碼在 Solidity 中定義了一個巢狀映射,用於組織和儲存 NFT 市場的上架清單。外層映射以 NFT 合約地址作為鍵值,讓智能合約能依各自的 NFT 集合對上架清單進行分類,確保市場可支援多個 NFT 集合。
內層映射以 token ID(每個 NFT 的唯一識別碼)作為鍵值,能夠有效管理單一 NFT 合約下的多筆上架清單。
mapping(address => mapping(uint256 => Listing)) public listings;
NFT 上架
listNFT 函式允許使用者在市場上架其 NFT 出售。它接受三個參數:NFT 合約、token ID 及價格。NFT 合約是指使用者欲上架 NFT 所屬集合的地址,token ID 是該集合中 NFT 的唯一識別碼,價格則是使用者希望出售 NFT 的金額。
函式首先驗證使用者是否為該 NFT 的擁有者,並確保價格大於零。通過這些檢查後,NFT 將從賣家轉移至市場合約,上架映射隨之更新,並以正確的參數觸發 Listed 事件。
function listNFT(address nftContract, uint256 tokenId, uint256 price)
external
notListed(nftContract, tokenId)
{
require(price > 0, "Price must be greater than zero");
require(IERC721(nftContract).ownerOf(tokenId) == msg.sender, "not owner");
IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId);
listings[nftContract][tokenId] = Listing(msg.sender, price);
emit Listed(nftContract, tokenId, msg.sender, price);
}
購買 NFT
buyNFT 函式允許使用者購買市場上已上架的 NFT。它接受兩個參數:nftContract(NFT 智能合約的地址)及 tokenId(欲購買 NFT 的唯一識別碼)。
函式首先驗證買家是否已傳送足夠的 Ether 以支付 NFT 的價格。驗證通過後,該上架清單將從映射中移除,款項安全轉移至賣家,NFT 則從市場合約轉移至買家地址,完成購買流程。
function buyNFT(address nftContract, uint256 tokenId)
public
payable
isListed(nftContract, tokenId)
{
Listing memory listing = listings[nftContract][tokenId];
require(msg.value >= listing.price, "Insufficient funds");
delete listings[nftContract][tokenId];
(bool sent, ) = listing.seller.call{value: listing.price}("");
require(sent, "Failed to send Ether");
IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId);
emit Purchase(nftContract, tokenId, msg.sender, listing.price);
}
批次購買 NFT
buyMultipleNFT 函式允許使用者在單筆交易中購買多個 NFT。它首先透過加總 tokenIds 陣列中所有 NFT 的價格來計算總金額,然後驗證買家是否已傳送足夠的資金以支付總額。
資金驗證通過後,函式會遍歷 tokenIds 陣列,對每個 NFT 呼叫 buyNFT 函式。這確保每個 NFT 都能單獨完成購買、款項轉移至賣家,且 NFT 發送至買家——遵循與 buyNFT 函式相同的流程。
function buyMultipleNFT(address nftContract, uint256 [] memory tokenIds)
external
payable
{
uint256 totalAmount;
for (uint256 i; i < tokenIds.length; i++){
Listing memory listing = listings[nftContract][tokenIds[i]];
totalAmount += listing.price;
}
require(msg.value >= totalAmount, "Insufficient funds");
for (uint256 i; i < tokenIds.length; i++){
buyNFT(nftContract, tokenIds[i]);
}
}
下架 NFT
delistNFT 函式允許賣家將其 NFT 從市場移除。它首先驗證 NFT 是否已上架,以及呼叫者是否為合法擁有者。驗證通過後,上架清單將從市場中刪除,有效地將 NFT 從出售清單中移除。
函式接著將 NFT 轉回給賣家,確保他們重新取得資產的所有權。最後,觸發 Delisted 事件以確認 NFT 已成功從市場中移除。
function delistNFT(address nftContract, uint256 tokenId)
external
isListed(nftContract, tokenId)
isSeller(nftContract, tokenId, msg.sender)
{
Listing memory listing = listings[nftContract][tokenId];
delete listings[nftContract][tokenId];
IERC721(nftContract).transferFrom(address(this), listing.seller, tokenId);
emit Delisted(nftContract, tokenId, listing.seller);
}
主要事件
合約會針對市場的重要操作觸發事件:
Listed:當 NFT 上架時觸發,記錄賣家、價格及 NFT 資訊等詳細資料。
Purchase:當買家購買 NFT 時觸發,確保交易可追溯性。
Delisted:表示 NFT 已成功從市場中移除,維護透明度。
event Listed(address indexed nftContract, uint256 indexed tokenId, address seller, uint256 price);
event Purchase(address indexed nftContract, uint256 indexed tokenId, address buyer, uint256 price);
event Delisted(address indexed nftContract, uint256 indexed tokenId, address seller
測試套件概覽
在 src 資料夾中建立 NFT.sol 檔案,並將以下程式碼加入該檔案。
pragma solidity ^0.8.18;
import "lib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";
import "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
contract NFT is ERC721, Ownable {
uint256 private _tokenIdCounter;
string private _baseTokenURI;
event Minted(address indexed to, uint256 tokenId);
constructor(string memory name, string memory symbol, string memory baseTokenURI) ERC721(name, symbol) Ownable(msg.sender) {
_baseTokenURI = baseTokenURI;
}
function mint(address to) external onlyOwner {
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter += 1;
_safeMint(to, tokenId);
emit Minted(to, tokenId);
}
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
function setBaseTokenURI(string memory newBaseTokenURI) external onlyOwner {
_baseTokenURI = newBaseTokenURI;
}
function getTokenCount() external view returns (uint256) {
return _tokenIdCounter;
}
}
在 test 資料夾中建立 market.t.sol 檔案,並將以下程式碼加入該檔案。
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {NFTMarketplace} from "../src/Market.sol";
import {NFT} from "../src/NFT.sol";
contract MarketTest is Test {
NFTMarketplace public market;
NFT public nft;
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
function setUp() public {
nft = new NFT("TestNFT", "tnft", "");
market = new NFTMarketplace();
nft.mint(user1);
nft.mint(user1);
nft.mint(user1);
}
function test_Listing() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
}
function test_BuyingNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
vm.startPrank(user2);
deal(user2, 10 ether);
market.buyNFT{value: 1 ether}(address(nft), 0);
assert(nft.ownerOf(0) == user2);
}
function test_BuyingMultipleNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
nft.approve(address(market), 1);
nft.approve(address(market), 2);
market.listNFT(address(nft), 0, 1 ether);
market.listNFT(address(nft), 1, 1 ether);
market.listNFT(address(nft), 2, 1 ether);
vm.startPrank(user2);
deal(user2, 10 ether);
uint256 [] memory nfts = new uint256[] (3);
nfts[0] = 0;
nfts[1] = 1;
nfts[2] = 2;
market.buyMultipleNFT{value: 3 ether}(address(nft), nfts);
}
function test_DelistingNFT() public {
vm.startPrank(user1);
nft.approve(address(market), 0);
market.listNFT(address(nft), 0, 1 ether);
assert(nft.ownerOf(0) == address(market));
market.delistNFT(address(nft), 0);
assert(nft.ownerOf(0) == address(user1));
}
}
上述測試套件確保 NFT 市場合約在其核心功能上能如預期運作。以下是各測試的說明:
設定測試環境
setUp 函式透過部署 NFTMarketplace 和 NFT 合約來初始化環境,並為 user1 鑄造三個 NFT 供測試使用。模擬 NFT 合約(NFT)部署時附帶 mint 函式以生成 NFT,同時實例化市場合約(NFTMarketplace)。User1 擁有所有已鑄造的 NFT,以便測試上架與下架功能。
測試 NFT 上架
test_Listing 函式確保 user1 可以在市場上架 NFT 出售。
User1 授權市場合約轉移 tokenId 0。
User1 以 1 Ether 的價格上架該 NFT。
測試確認 listNFT 函式運作正常,且 NFT 已轉移至市場合約。
測試單筆 NFT 購買
test_BuyingNFT 函式模擬 user2 購買已上架 NFT 的流程。
User1 以 1 Ether 上架 tokenId 0。
User2 收到 10 Ether 並呼叫 buyNFT 函式,傳送 1 Ether 購買該 NFT。
購買完成後,測試驗證 user2 是 tokenId 0 的新擁有者。
測試批次 NFT 購買
test_BuyingMultipleNFT 函式測試在單筆交易中購買多個 NFT 的能力。
User1 以每個 1 Ether 的價格上架三個 NFT(tokenId 0、1、2)。
User2 呼叫 buyMultipleNFT,傳入 token ID 並傳送 3 Ether。
測試確保 user2 成為所有三個 NFT 的擁有者,且市場正確處理多筆購買。
測試 NFT 下架
test_DelistingNFT 函式驗證 user1 可以將其 NFT 從市場下架。
User1 以 1 Ether 上架 tokenId 0 後呼叫 delistNFT。
NFT 返回給 user1,測試確認所有權已恢復至 user1。
執行測試
forge test在 Linea 上部署與驗證
將所需變數加入 .env 檔案,確保其位於專案的根目錄層級:
LINEA_RPC_URL=https://linea-mainnet.infura.io/v3/
PRIVATE_KEY=*************************************************
將以下內容加入 foundry.toml:
[etherscan]
linea = { key = "${LINEA_API_KEY}", url = "https://api-sepolia.lineascan.build/api" }
建立 Makefile 並加入以下內容:
deploy:
forge create src/Market.sol:NFTMarketplace --rpc-url $(LINEA_RPC_URL) --private-key $(PRIVATE_KEY)
verify:; forge verify-contract --rpc-url $(LINEA_RPC_URL) --chain linea <contract address> src/Market.sol:NFTMarketplace
部署
make deploy驗證
make verify結語
在 Linea 上建構 NFT 市場,讓開發者能夠親身實踐智能合約開發、Solidity 程式設計及 web3 互動。本指南涵蓋了去中心化市場的核心功能,包括上架、購買、批次購買及下架 NFT,同時確保交易安全且高效。
我們也逐步介紹了使用 Foundry 實作與測試智能合約的流程,以及在 Linea 上的部署與驗證過程。透過本指南,您現在已具備建構、測試及部署自己的 NFT 市場所需的基礎知識。
如需更多實作教學及區塊鏈開發深度解析,歡迎訂閱 MetaMask Developer YouTube 頻道,並追蹤我們的 Twitter 以掌握最新動態。