GM!前些日子在幣圈亂玩,一路從幣玩到了NFT再玩到自己動手寫合約…所以,我們就久違的來一篇關於智能(慧)合約(Smart Contract)的教學吧!
智能合約是什麼?
智能合約(私心比較喜歡稱作智慧合約),Smart Contract,是一種在區塊鏈所使用的合約,通常運作在有圖靈設計的區塊鏈中,其中最著名的就是以太鏈(Ethereum)的EVM。智能合約的存在,提供區塊鏈更多可以延伸的應用方式,例如最近比較常被聽到的 NFT 即是智能合約的應用之一。
而根據不同的區塊鏈以及 VM 設計,語法也會有所不同。其中常被使用的以太鏈較被廣泛使用的是 Solidity。
ERC721
ERC-721 是一種合約協議的代號,他是目前 NFT 的基礎協議,用作發行擁有不同代號的有限量或無限量代幣(Token)。而因為他的唯一編碼方式以及使用 Metadata 來提供更多資訊的特色,因此被廣泛應用在各領域。
ERC721A
ERC-721A 是由 ERC-721 的改良,因原本使用的 ERC-721 其語法的因素,導致在鑄造(Mint)多個 NFT 時會產生大量的手續費(Gas Fee),而 ERC-721A 大幅度地減少了手續費的消耗,因此在目前的 NFT 世界被廣泛使用。
ERC-721A 的 A 是一個知名專案 Azuki (紅豆)的開頭。
Github: chiru-labs/ERC721A: https://ERC721A.org (github.com)
開始之前
你會需要一個 Node.js 的開發環境,你可以透過 NVM 安裝,或者到官方網站下載符合你作業系統的版本進行安裝。
NVM
這是一個方便用於管理Node.js版本的工具,通常用於有多版本需求或者是懶人安裝用。
在 Ubuntu/macOS 可以直接執行這個命令安裝:
1 |
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash |
Windows 版本請參考:coreybutler/nvm-windows: A node.js version management utility for Windows. Ironically written in Go. (github.com)
安裝完成後再執行
1 2 3 4 5 6 |
# 安裝 Node.js 16 版 nvm install 16 # 使用 16 版 nvm use 16 # 固定預設版本 nvm alias default 16 |
然後再執行
1 |
node -v |
確認版本,預期執行結果會類似
1 2 |
> $ node -v v16.14.0 |
專案環境設定
建立一個新的資料夾test-erc721a
,然後初始化成 Node.js 的專案資料夾
1 2 3 4 5 6 |
# 建立資料夾 mkdir test-erc721a # 進入資料夾 cd test-erc721a # 初始化成 Node.js 專案 npm init -y |
執行命令後會詢問是否要安裝套件,請按下 Enter 直接安裝。
初始化完成後,再來安裝必要套件 Hardhat,這是一個用於 Solidity 合約語法的開發工具以及框架。
1 |
npm install --save-dev hardhat |
安裝完成後執行
1 |
npx hardhat |
這邊我們選擇 Create a basic sample project
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
> $ npx hardhat ⬡ 16.14.0 888 888 888 888 888 888 888 888 888 888 888 888 888 888 888 8888888888 8888b. 888d888 .d88888 88888b. 8888b. 888888 888 888 "88b 888P" d88" 888 888 "88b "88b 888 888 888 .d888888 888 888 888 888 888 .d888888 888 888 888 888 888 888 Y88b 888 888 888 888 888 Y88b. 888 888 "Y888888 888 "Y88888 888 888 "Y888888 "Y888 👷 Welcome to Hardhat v2.9.9 👷 ✔ What do you want to do? · Create a basic sample project ✔ Hardhat project root: · ./test-erc721a ✔ Do you want to add a .gitignore? (Y/n) · y ✔ Do you want to install this sample project's dependencies with npm (@nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)? (Y/n) · y |
環境設定完成之後你會看到資料夾中多了這些東西
1 2 3 4 5 6 7 8 9 |
. ├── README.md ├── contracts ├── hardhat.config.js ├── node_modules ├── package-lock.json ├── package.json ├── scripts └── test |
- contracts: 智能合約檔案的資料夾
- migrations: 部署合約腳本的資料夾
- test: 測試腳本的資料夾
- hardhat.config.js: 一些設定的資料夾
到此,基本的環境設定就完成了。
合約製作
我們在這裡要安裝 OpenZeppelin 所提供的合約庫,這個開源的合約庫提供了相當多的合約協議可供開發使用,而且不太需要擔心安全性問題(但不是完全安全),畢竟是開源的,內容大家都能看到,有異常也容易被發現。
1 |
npm install --save-dev @openzeppelin/contracts |
然後我們還需要安裝 ERC-721A 的合約庫。
1 |
npm install --save-dev erc721a |
接下來在 contracts 建立一個新的檔案:Mint.sol,然後貼上以下內容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
//SPDX-License-Identifier: MIT pragma solidity ^0.8.4; import "@openzeppelin/contracts/utils/Strings.sol"; // Import Ownable from the OpenZeppelin Contracts library import "@openzeppelin/contracts/access/Ownable.sol"; // ERC-721A import "erc721a/contracts/ERC721A.sol"; contract MyMint is ERC721A, Ownable { using Strings for uint256; string public baseUri = ""; string public uriSuffix = ".json"; bool public enableMint = false; event SetBaseURI(address _from); constructor( string memory name_, // Name of the token string memory symbol_ // Symbol of the token ) ERC721A(name_, symbol_) {} function mint(uint256 quantity) external payable { require(enableMint, "Cannot mint"); _mint(msg.sender, quantity); } // override function _baseURI() override internal view virtual returns (string memory) { return baseUri; } // override start token id from 0 to 1 function _startTokenId() override internal view virtual returns (uint256) { return 1; } function airdrop(uint256 quantity, address _receiver) public onlyOwner { _safeMint(_receiver, quantity); } function setMint(bool value) public onlyOwner { enableMint = value; } function setBaseUri(string memory uri) public onlyOwner { baseUri = uri; emit SetBaseURI(msg.sender); } function setUriSuffix(string memory _uriSuffix) public onlyOwner { uriSuffix = _uriSuffix; } function tokenURI(uint256 _tokenId) public view virtual override returns (string memory) { require(_exists(_tokenId), "URI query for nonexistent token"); string memory currentBaseURI = _baseURI(); return bytes(currentBaseURI).length > 0 ? string(abi.encodePacked(currentBaseURI, _tokenId.toString(), uriSuffix)) : ""; } } |
這個合約主要提供了以下功能:
- mint: 鑄造
- tokenURI: 讀取 NFT 的 Metadata
- airdrop: 空投 NFT 給指定位址的人
- setMint: 啟動或停止鑄造
- setBaseUri: 設定 NFT 的 Metadata URI,主要用於設定 NFT 圖片位址、細節內容等
這些都是目前主要的功能,不過還缺少了一些較細節的設定,例如價錢設定、錢包位址可鑄造的數量等等,但為了教學與入門方便,我們先省略這些細節,之後的文章再來增加這些功能…如果我有空寫的話。
實際測試
完成合約之後,就要來測試啦!
首先,先開啟一個新的終端機,然後輸入以下指令來啟動單機測試用的節點:
1 |
npx hardhat node |
啟動之後,不要關掉,另外開一個終端機,輸入以下指令來開啟連線
1 |
npx hardhat console --network localhost |
輸入完成後你應該會看到類似這樣的畫面:
1 2 3 4 |
> $ npx hardhat console Welcome to Node.js v16.14.0. Type ".help" for more information. > |
接下來輸入以下這個指令:
1 |
> await ethers.provider.listAccounts() |
注意:以下指令不包括「>」符號,只需要輸入後方的內容即可。
你會看到一大堆的位址,如下面所示,但注意!這些都是測試位址,請不要轉你的以太幣到這些位址,會消失!或者是在別人的錢包!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
> await ethers.provider.listAccounts() [ '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', '0x90F79bf6EB2c4f870365E785982E1f101E93b906', '0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65', '0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc', '0x976EA74026E726554dB657fA54763abd0C3a0aa9', '0x14dC79964da2C08b23698B3D3cc7Ca32193d9955', '0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f', '0xa0Ee7A142d267C1f36714E4a8F75612F20a79720', '0xBcd4042DE499D14e55001CcbB24a551F3b954096', '0x71bE63f3384f5fb98995898A86B02Fb2426c5788', '0xFABB0ac9d68B0B445fB7357272Ff202C5651694a', '0x1CBd3b2770909D4e10f157cABC84C7264073C9Ec', '0xdF3e18d64BC6A983f673Ab319CCaE4f1a57C7097', '0xcd3B766CCDd6AE721141F452C550Ca635964ce71', '0x2546BcD3c84621e976D8185a91A922aE77ECEc30', '0xbDA5747bFD65F08deb54cb465eB87D40e51B197E', '0xdD2FD4581271e230360230F9337D5c0430Bf44C0', '0x8626f6940E2eb28930eFb4CeF49B2d1F2C9C1199' ] |
接下來輸入以下指令部署你的 ERC-721A 智能合約到你的測試節點上,在部署的同時可以觀察測試節點的那個終端機,會有有趣的內容喔~
1 2 3 4 |
> const MyNFT = await ethers.getContractFactory("MyNFT"); undefined > const myNFT = await MyNFT.deploy("Test Token", "TESTTOKEN"); undefined |
輸入以下指令來看合約位址
1 2 |
> myNFT.address '0x5FbDB2315678afecb367f032d93F642f64180aa3' |
以上都沒有遇到問題後,這表示你的部署已經完成了,接下來我們要來手動玩一次鑄造啦!
1 2 |
> await myNFT.totalSupply() BigNumber { value: "0" } |
先來確認一下數字是不是為 0,看起來沒什麼問題。然後輸入鑄造的命令:
1 |
> await myNFT.mint(1) |
然後你會看到一大堆錯誤
1 2 |
Uncaught: Error: cannot estimate gas; transaction may fail or may require manual gas limit [ See: https://links.ethers.org/v5-errors-UNPREDICTABLE_GAS_LIMIT ] (reason="Error: VM Exception while processing transaction: reverted with reason string 'Cannot mint'" |
這是正常的,因為我們在合約中有一段限制的程式,在還沒設定啟用鑄造功能前,鑄造功能是無法使用的!
1 2 3 4 5 |
function mint(uint256 quantity) external payable { // 就是這一段啦! require(enableMint, "Cannot mint"); _mint(msg.sender, quantity); } |
所以我們要先啟用鑄造功能
1 |
> await myNFT.setMint(true) |
然後再輸入一次
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
> await myNFT.mint(1) { hash: '0xc094e1eaa383959b9c48eb1ad2d1b9b21ced276a0accfa8c6f85be1afd911a3a', type: 2, accessList: [], blockHash: '0xd688f8b3189270059941ec5c299ad685d11d9179f587faecb7ffc73528e0387d', blockNumber: 3, transactionIndex: 0, confirmations: 1, from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', gasPrice: BigNumber { value: "687132203" }, maxPriorityFeePerGas: BigNumber { value: "0" }, maxFeePerGas: BigNumber { value: "869651694" }, gasLimit: BigNumber { value: "75789" }, to: '0x5FbDB2315678afecb367f032d93F642f64180aa3', value: BigNumber { value: "0" }, nonce: 2, data: '0xa0712d680000000000000000000000000000000000000000000000000000000000000001', r: '0x0c329f1380200fdad453eed33bbe88e630940c179c2cf3bf5dcbcccc201d4405', s: '0x7dee64d0ec183abce8b02ca892d25cc482b65343dfe632c75feac5065d2ae9fa', v: 0, creates: null, chainId: 31337, wait: [Function (anonymous)] } |
欸嘿!成功啦!我們可以用 totalSupply 確認數量是不是增加了。
1 2 |
> await myNFT.totalSupply() BigNumber { value: "1" } |
然後設定一下 BaseUri
1 |
> const setUri = await myNFT.setBaseUri("https://example.com/"); |
再用 tokenURI 確認回傳的位址組合正不正確
1 2 |
> await myNFT.tokenURI(1) 'https://example.com/1.json' |
看起來一切正常,恭喜你獲得基本款 NFT 智能合約啦!
自動測試
手動體驗一次測試後,雖然有趣,但如果每次開發功能都要手動跑一次的話,那也太累人了。可是又不能不測試,畢竟智能合約一但部署至區塊鏈上就無法被更改(註1),如果有嚴重問題的話就會相當麻煩。
這時候我們就需要自動測試啦!
在 test 資料夾中將原本的測試腳本內容刪除,更換成以下內容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
const { expect } = require("chai"); const { ethers } = require("hardhat"); const { expectRevert } = require("@openzeppelin/test-helpers"); const name = "TestMint"; const symbol = "TEST"; describe("Mint " + name, function () { let myNFT; this.beforeAll(async () => { const MyNFT = await ethers.getContractFactory("MyNFT"); // Deploy to test myNFT = await MyNFT.deploy(name, symbol); // wait for the contract to be deployed await myNFT.deployed(); }); it("Should return symbol as " + symbol, async function () { // test the symbol of the contract expect(await myNFT.symbol()).to.equal(symbol); }); it("Should not mint due to mint not available", async function () { await expectRevert(myNFT.mint(1), "Cannot mint"); }); it("Should enable mint", async function () { const enableMint = await myNFT.setMint(true); await enableMint.wait(); expect(await myNFT.enableMint()).to.equal(true); }); it("Should mint 1 NFT", async function () { const mint = await myNFT.mint(1); await mint.wait(); expect(await myNFT.totalSupply()).to.equal(1); }); it("Should set token base uri", async function () { const setUri = await myNFT.setBaseUri("https://example.com/"); await setUri.wait(); expect(await myNFT.baseUri()).to.equal("https://example.com/"); }); it("Should return NFT tokenUri as https://example.com/1.json", async function () { expect(await myNFT.tokenURI(1)).to.equal("https://example.com/1.json"); }); }); |
然後在終端機執行
1 |
npx hardhat test |
好了,剛剛我們的操作現在只要一行指令就可以自動幫我們完成測試,是不是很方便呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 |
> $ npx hardhat test ⬡ 16.14.0 Mint TestMint ✔ Should return symbol as TEST ✔ Should not mint due to mint not available (62ms) ✔ Should enable mint (46ms) ✔ Should mint 1 NFT (50ms) ✔ Should set token base uri (45ms) ✔ Should return NFT tokenUri as https://example.com/1.json 6 passing (1s) |
關於自動測試的部分,可以參考 Mocha 以及 chai,這是 hardhat 預設使用的工具,同時也是 Node.js 在執行自動化測試時相當常用的工具。
到這裡,你已經擁有你的 ERC-721A 智能合約以及學到了最基本的測試。接下來的文章會為大家講解,如何將智能合約部署到測試的區塊鏈,以及,如何用網頁瀏覽器與你的智能合約互動。
我們下回再見!
註1: 智能合約有提供一種叫做 Proxy 的功能,他可以允許合約採用代理模式呼叫,如此一來便可隨時更新背後呼叫的合約,以達到類似更新的作用。
嗨,我建立了一個 Discord 社群,如果有任何想交流的或想聊聊的歡迎加入!
Discord: https://discord.gg/uq8dhu5X