發行 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。」

本日總結

在本章中,你學到了:

  1. ERC-721 標準:非同質化代幣的介面規範
  2. 完整 NFT 合約:鑄造、限量、每人限額
  3. 揭示功能:鑄造時隱藏,之後一次揭示
  4. 版稅機制:使用 ERC2981 標準
  5. IPFS 儲存:去中心化的 metadata 儲存
  6. 與前端互動:用 ethers.js 鑄造與查詢

下一章,我們將實作一個去中心化交易所!

解鎖完整教學內容

本章為付費內容。加入專案即可解鎖超過 5000 字的深度解析,包含 10 個以上神級 Prompt 與真實 Source Code 範例!