發行 NFT(ERC-721)
NFT (Non-Fungible Token) 是「非同質化代幣」。與 ERC-20 不同,每一個 NFT 都是獨一無二的。想像一下:
- ERC-20 像「鈔票」:每一張 100 元都一模一樣
- ERC-721 像「收藏卡」:每一張都是獨特的,有不同的編號、稀有度、圖片
ERC-721 標準介面
interface IERC721 {
function balanceOf(address owner) external view returns (uint256);
function ownerOf(uint256 tokenId) external view returns (address);
function safeTransferFrom(address from, address to, uint256 tokenId) external;
function transferFrom(address from, address to, uint256 tokenId) external;
function approve(address to, uint256 tokenId) external;
function getApproved(uint256 tokenId) external view returns (address);
function setApprovalForAll(address operator, bool approved) external;
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
完整 NFT 合約
// contracts/VibeNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Royalty.sol";
contract VibeNFT is ERC721Enumerable, ERC721URIStorage, Ownable, ERC721Royalty {
// === 基本設定 ===
uint256 public constant MAX_SUPPLY = 10000; // 最大供應量
uint256 public constant MINT_PRICE = 0.01 ether; // 鑄造價格(0.01 ETH)
uint256 public constant MAX_PER_WALLET = 5; // 每個錢包最多鑄造 5 個
uint256 public totalMinted;
bool public revealed;
string public revealedURI; // 揭示後的 metadata URI
string public unrevealedURI; // 揭示前的 placeholder URI
// === 事件 ===
event NFTMinted(address indexed minter, uint256 indexed tokenId);
event NFTsRevealed(string revealedURI);
constructor(
string memory _unrevealedURI,
string memory _revealedURI
) ERC721("Vibe NFT Collection", "VIBNFT") Ownable(msg.sender) {
unrevealedURI = _unrevealedURI;
revealedURI = _revealedURI;
// 設定版稅(5%,使用 OpenZeppelin 標準)
_setDefaultRoyalty(msg.sender, 500); // 500 = 5%
}
// === 鑄造 ===
function mintNFT(uint256 _quantity) public payable {
require(_quantity > 0, "數量必須大於 0");
require(totalMinted + _quantity <= MAX_SUPPLY, "已達最大供應量");
require(balanceOf(msg.sender) + _quantity <= MAX_PER_WALLET, "超過每人最大鑄造數量");
require(msg.value >= MINT_PRICE * _quantity, "ETH 不足");
for (uint256 i = 0; i < _quantity; i++) {
totalMinted++;
_safeMint(msg.sender, totalMinted);
emit NFTMinted(msg.sender, totalMinted);
}
}
// === 揭示 ===
function reveal(string memory _revealedURI) public onlyOwner {
revealed = true;
revealedURI = _revealedURI;
emit NFTsRevealed(_revealedURI);
}
// === Metadata ===
function tokenURI(
uint256 tokenId
) public view override(ERC721, ERC721URIStorage) returns (string memory) {
require(ownerOf(tokenId) != address(0), "Token 不存在");
if (!revealed) {
return unrevealedURI;
}
// 如果揭示後,每個 NFT 可以有不同的 metadata
// 這裡用簡化版:全部指向同一個 revealedURI
return revealedURI;
}
// === 提領合約中的 ETH ===
function withdraw() public onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "沒有 ETH 可以提領");
// 70% 給擁有者,30% 給金庫
uint256 ownerShare = (balance * 70) / 100;
uint256 treasuryShare = balance - ownerShare;
payable(owner()).transfer(ownerShare);
payable(0x...TreasuryAddress...).transfer(treasuryShare);
}
// === 必要覆寫(Solidity 多重繼承) ===
function _increaseBalance(
address account,
uint128 value
) internal override(ERC721, ERC721Enumerable) {
super._increaseBalance(account, value);
}
function _update(
address to,
uint256 tokenId,
address auth
) internal override(ERC721, ERC721Enumerable) returns (address) {
return super._update(to, tokenId, auth);
}
function supportsInterface(
bytes4 interfaceId
) public view override(ERC721, ERC721Enumerable, ERC721URIStorage, ERC721Royalty) returns (bool) {
return super.supportsInterface(interfaceId);
}
}
準備 NFT Metadata
NFT 的 metadata 通常是 JSON 格式,儲存在去中心化儲存(IPFS)上:
// metadata/unrevealed.json
{
"name": "Vibe NFT Collection",
"description": "這是一個還未揭示的 NFT⋯⋯ 敬請期待!",
"image": "ipfs://QmPlaceholder/placeholder.png",
"attributes": []
}
// metadata/1.json
{
"name": "Vibe NFT #1",
"description": "Vibe Tutor 第一個 NFT!慶祝我們進入 Web3 的世界!",
"image": "ipfs://QmYourHash/images/1.png",
"attributes": [
{
"trait_type": "稀有度",
"value": "傳說"
},
{
"trait_type": "類型",
"value": "Genesis"
},
{
"trait_type": "背景",
"value": "宇宙"
}
]
}
上傳到 IPFS
# 安裝 IPFS CLI
brew install ipfs
# 啟動 IPFS 節點
ipfs init
ipfs daemon &
# 新增檔案到 IPFS
ipfs add -r ./metadata
ipfs add -r ./images
# 輸出範例
# added QmXyz123 metadata
# added QmAbc456 images
也可以使用 Pinata(https://pinata.cloud)來上傳檔案,更方便。
部署並與前端互動
// scripts/mint_interact.js
const hre = require("hardhat");
async function main() {
// 載入已部署的合約
const contractAddress = "0x...你的合約地址...";
const VibeNFT = await hre.ethers.getContractFactory("VibeNFT");
const nft = VibeNFT.attach(contractAddress);
const [owner, user1] = await hre.ethers.getSigners();
// 鑄造 NFT
console.log("\n=== 鑄造 NFT ===");
const mintTx = await nft.connect(user1).mintNFT(2, {
value: hre.ethers.parseEther("0.02") // 2 個 NFT = 0.02 ETH
});
await mintTx.wait();
console.log(`使用者 ${user1.address} 鑄造了 2 個 NFT`);
// 查詢使用者擁有的 NFT
const balance = await nft.balanceOf(user1.address);
console.log(`使用者擁有 ${balance.toString()} 個 NFT`);
// 查詢 Token URI
for (let i = 1; i <= 2; i++) {
const uri = await nft.tokenURI(i);
console.log(`Token ${i} URI:`, uri);
}
// 揭示
console.log("\n=== 揭示 NFT ===");
const revealTx = await nft.connect(owner).reveal(
"ipfs://QmRevealedURI/revealed.json"
);
await revealTx.wait();
console.log("NFT 已揭示!");
}
main().catch(console.error);
使用 Vibe Coding 發行 NFT
🔥 【NFT 發行詠唱範例】
「請幫我發行一個 ERC-721 NFT 合約:1. 總量 5000 個,每個錢包最多鑄造 3 個。2. 鑄造價格 0.005 ETH。3. 有揭示功能:鑄造時顯示 placeholder,之後可以揭示。4. 每次轉售收取 7% 版稅。5. metadata 儲存在 IPFS 上。6. 合約擁有者可以提領 ETH。」
本日總結
在本章中,你學到了:
- ✅ ERC-721 標準:非同質化代幣的介面規範
- ✅ 完整 NFT 合約:鑄造、限量、每人限額
- ✅ 揭示功能:鑄造時隱藏,之後一次揭示
- ✅ 版稅機制:使用 ERC2981 標準
- ✅ IPFS 儲存:去中心化的 metadata 儲存
- ✅ 與前端互動:用 ethers.js 鑄造與查詢
下一章,我們將實作一個去中心化交易所!