前一篇文章我們已經把作為NFT形象使用的圖片資源以及metadata都準備好了,並且已經上傳至ipfs,接下來就讓我們來進行以太坊智能合約的開發吧!
我打算將NFT發行至以太坊,因此撰寫智能合約的語言是Solidity
,由於我們只是為了練習所以不會花費真正的ETH來部屬我們的合約,後續部屬時我們將會使用以太坊的測試網而非主網,而本篇文章不會教學如何創建以太坊錢包,不熟悉的人可以自行google並準備好metamask
以供後續使用。
目前主要發行以太坊NFT的協議是ERC-721
及ERC-1155
,我對ERC-1155並沒有過多的研究,這邊將會在ERC-721的基礎上開發我們的合約。
在開發以太坊智能合約時有一個我們不得不提到的東西,那就是openzeppelin。
openzeppelin
是一個安全的智能合約開發庫,智能合約上鏈後就無法再更改,因此安全性非常重要,而openzeppelin已經幫我們實作了多種ERC token規範,並且還有更多的擴充功能,我們會使用這個庫以避免重複造輪子且又要承擔安全性的風險。
如上所述,我們將會使用ERC721.sol並加上一些功能例如: mint/開啟暫停mint...
這邊再介紹另一個好用的工具Contracts Wizard,一些常見的功能我們只要勾選他就能幫我們產出code,並且還能快速在remix online IDE上開啟。
勾選Mintable
、Auto Increment Ids
、Pausable
將會產出如下程式碼:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract MyToken is ERC721, Pausable, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;
constructor() ERC721("MyToken", "MTK") {}
function pause() public onlyOwner {
_pause();
}
function unpause() public onlyOwner {
_unpause();
}
function safeMint(address to) public onlyOwner {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
}
function _beforeTokenTransfer(address from, address to, uint256 tokenId)
internal
whenNotPaused
override
{
super._beforeTokenTransfer(from, to, tokenId);
}
}
以上便是我們合約的基礎,這個合約可以mint名為MyToken
的token,並且token id會自動累加,另外合約owner也可以改變狀態讓mint被暫停。ERC721.sol
中的_mint()
會呼叫_beforeTokenTransfer()
,這邊override加上了whenNotPaused
這個modifier,因此若pause則無法safeMint()
。
然而以上功能還不夠完善,目前大部分PFP類NFT項目都是讓大家mint盲盒
,之後再找個時間統一解盲
,就像是我們一開始買了遊戲王卡包還不知道裡面有什麼卡,而解盲就是開卡包的概念。
另外我們也會訂個NFT總供應量,畢竟我們製作出的圖有限,必須讓mint的人都能開到獨一無二的圖片,以下我們將開始實現這些功能。
我們借助腳手架工具truffle來初始化我們的專案。
npm install -g truffle
# after installed...
truffle init
並透過npm
來管理專案及套件。
npm init -y
# @truffle/hdwallet-provide 是我們部屬合約至鏈上時會用到
# @openzeppelin/contracts 引用openzeppelin的合約
npm install @truffle/hdwallet-provide @openzeppelin/contracts
添加.gitignore
檔案。
.secret
node_modules
build
首先講解一下這個功能是如何實現的,讓我們看看EIP-721提案怎麼說:
The metadata extension is OPTIONAL for ERC-721 smart contracts. This allows your smart contract to be interrogated for its name and for details about the assets which your NFTs represent.
/// @title ERC-721 Non-Fungible Token Standard, optional metadata extension
/// @dev See https://eips.ethereum.org/EIPS/eip-721
/// Note: the ERC-165 identifier for this interface is 0x5b5e139f.
interface ERC721Metadata /* is ERC721 */ {
/// @notice A descriptive name for a collection of NFTs in this contract
function name() external view returns (string _name);
/// @notice An abbreviated name for NFTs in this contract
function symbol() external view returns (string _symbol);
/// @notice A distinct Uniform Resource Identifier (URI) for a given asset.
/// @dev Throws if `_tokenId` is not a valid NFT. URIs are defined in RFC
/// 3986. The URI may point to a JSON file that conforms to the "ERC721
/// Metadata JSON Schema".
function tokenURI(uint256 _tokenId) external view returns (string);
}
tokenURI
將返回一個json文件,裡面可以記載該NFT的各種資訊,以上規範openzeppelin已經幫我們實作了,但我們仍須修改以符合我們的需求。
這邊我先寫個sudo code來表示:
contract Test {
string public baseURI = "ipfs://aaa"; // 正式token對應的資源路徑
string public unrevealedURI = "ipfs://bbb"; // 盲盒狀態對應的資源路徑
bool public revealed = false; // 是否已解盲
// 加上override複寫
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
if (revealed) {
// return 由baseURI加上tokenId組成的metadata json資源
} else {
// return unrevealedURI返回的固定盲盒圖片,所有人在解盲前都看到同樣的畫面
}
}
// 只有owner可以改變解盲狀態
function setRevealed(bool flag) public onlyOwner {
revealed = flag;
}
}
概念理清楚後我們就來coding吧!
首先創建合約主檔案,在/contracts
底下新增MyToken.sol
,
在一開始Contracts Wizard生成之程式碼的基礎上,加上剛剛的邏輯。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract MyToken is ERC721, Pausable, Ownable {
string public baseURI = "";
string public unrevealedURI = "ipfs://[UNREVEALED_CID]/";
bool public revealed = false;
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;
constructor() ERC721("MyToken", "MTK") {
// id counter從1開始記
_tokenIdCounter.increment();
}
function pause() public onlyOwner {
_pause();
}
function unpause() public onlyOwner {
_unpause();
}
function safeMint(address to) public payable {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
}
function _beforeTokenTransfer(address from, address to, uint256 tokenId) internal override whenNotPaused {
super._beforeTokenTransfer(from, to, tokenId);
}
function setRevealed(bool flag) public onlyOwner {
revealed = flag;
}
// 預留更改baseURI的方法,通常在解盲前最後一刻才會透漏baseURI
// 否則metadata洩漏的話可能會讓有心人士提早知道哪個tokenId是稀有圖
function setBaseURI(string memory _newBaseURI) public onlyOwner {
baseURI = _newBaseURI;
}
// 預留更改盲盒URI的方法,若一開始就確定unrevealedURI的話可以省略
function setUnrevealedURI(string memory _newUnrevealedURI) public onlyOwner {
unrevealedURI = _newUnrevealedURI;
}
// ERC721.sol內的tokenURI()會去獲取_baseURI(),這邊override以返回自己定義的baseURI
function _baseURI() internal view virtual override returns (string memory) {
return baseURI;
}
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
if (!revealed) {
return string(abi.encodePacked(unrevealedURI, "hidden.json"));
}
return string(abi.encodePacked(super.tokenURI(tokenId), ".json"));
}
}
這邊可以注意一下tokenURI()
,若還沒解盲會返回unrevealedURI
加上hidden.json
字串拼接而成的盲盒metadata資源路徑,因此我們可以先產出盲盒的圖及metadata並先行上傳ipfs,接著就可以把URI寫入檔案最上方的state variable了。
而在已解盲的狀況下,則會呼叫被繼承方ERC721的tokenURI()
並帶入tokenId,由於我們有複寫_baseURI()
因此最終會返回該文件定義的baseURI
與tokenId拼接而成的路徑,接著我們加上了.json
的檔名,形成這樣的格式: ipfs://abcdefg/3.json
。
為了防止盲盒時期洩漏解盲後的metadata,此時我們還不會執行setBaseURI()
,因此baseURI我們就先讓他留空吧。
接下來我們要限制NFT發行量,並且原先的mint是免費的,我們將為他加上價格,這樣有人mint的話ETH就會進到合約地址,我們才能賺錢錢!
contract MyToken is ERC721, Pausable, Ownable {
uint256 public MAX_SUPPLY = 100;
uint256 public MINT_PRICE = 0.01 ether;
function totalSupply() public view returns (uint256) {
return _tokenIdCounter.current() - 1;
}
function safeMint(address to) public payable {
require(totalSupply() < MAX_SUPPLY, "Can't mint more");
require(msg.value >= MINT_PRICE, "Not enough ether sent");
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
}
}
非常簡單的幾行程式碼,首先定義了2個state variables,並添加totalSupply()
取得目前發行到多少編號了,接著在safeMint()
加上兩個require
,往後mint的transaction必須帶有足夠的ETH才能完成mint的操作。
當有人購買,合約地址中的ETH便會增加,身為owner我們要如何提取其中的ETH呢?讓我們加上最後一個方法。
contract MyToken is ERC721, Pausable, Ownable {
function withdraw() public onlyOwner {
require(address(this).balance > 0, "Balance is 0");
payable(owner()).transfer(address(this).balance);
}
}
以上方法將一次把合約中所有ETH都轉走,若是真正要運行項目最好還是把提取的量設成參數,否則一次把錢領光的話別人可能會認為這是個rug項目而造成恐慌。
以上合約部分就完成了,完整代碼在這。
在/migrations
底下新增檔案2_token_migration.js
,前面的數字是必須的。
const MyToken = artifacts.require("MyToken");
module.exports = function (deployer) {
deployer.deploy(MyToken);
};
接著修改truffle-config.js
。
部屬合約時我將透過節點供應商infura,這也是我們之前安裝@truffle/hdwallet-provider
的目的。
免費註冊infura後創建一個project,把ENDPOINTS
中的API填入truffle config,這邊要記得把MAINNET
切成Rinkeby
。
const HDWalletProvider = require("@truffle/hdwallet-provider");
module.exports = {
// ...
networks: {
// ...
rinkeby: {
provider: () =>
new HDWalletProvider(
mnemonic,
"[your_infura_API]"
),
network_id: 4, // rinkeby's id
gas: 5500000,
confirmations: 2, // # of confs to wait between deployments. (default: 0)
timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
skipDryRun: true, // Skip dry run before migrations? (default: false for public nets )
networkCheckTimeout: 10000,
},
}
}
另外在config中有這麼一段程式碼:
const mnemonic = fs.readFileSync(".secret").toString().trim();
我們需要提供一組錢包的註記詞以作為合約的部屬者,在根目錄創建.secret
檔案並把註記詞寫在裡面。還記得我們之前有把secret檔案給加入.gitignore
嗎?註記詞洩漏意味著你錢包內所有資產都可能被偷走,所以secret檔案只有你自己能保管,千萬不要推上雲端或是傳給其他人。
接下來先執行compile:
truffle compile
部屬到Rinkeby測試網:
truffle migrate --network rinkeby
若成功你就能看到部屬合約的transaction hash以及合約地址,到rinkeby etherscan可以查看transaction跟合約。
然而你會發現我們無法直接在etherscan上跟我們的合約進行交互,這是因為我們的合約沒有開源,對我來說,一個開源的NFT項目比較能讓人信賴,同時也能看出開發團隊的技術實力。
你可以直接在etherscan上手動讓合約開源,但是這樣的操作有點麻煩,所以我們可以透過另一個npm package: truffle-plugin-verify。
首先安裝truffle-plugin-verify
:
npm install -D truffle-plugin-verify
接著我們前往etherscan申請一組API key。
編輯truffle-config.js
:
module.exports = {
// ...
plugins: ["truffle-plugin-verify"],
api_keys: {
etherscan: "MY_API_KEY",
},
}
執行verify:
truffle run verify MyToken --network rinkeby
可以看到Contract右上角多了一個綠色勾勾,即代表開源的意思。
我們往後就能直接在etherscan上對合約進行mint之類的操作了。
我們可以直接在etherscan上測試: 連接錢包帶上足夠的ETH,並填入要mint錢包的地址,並按下Write就可以在metamask上送出交易。
mint成功後我們就能在測試網opensea上看到剛剛mint的NFT囉~
並且目前是盲盒狀態,所有token都會讀取到我們合約中unrevealedURI指向的資源。
合約的部分本篇就講解到這邊,這只是一個粗淺的合約,NFT還有很多有趣的玩法像是白名單機制,這邊就沒有實作這個功能了。
下一篇將會製作一個前端網頁讓使用者可以在上面mint我們發行的NFT,畢竟不是每個人都會直接在etherscan上操作,那麼我們就下篇見啦!