本系列文將分享如何使用 Python 的 Brownie 框架開發 ERC-721,也就是 NFT。並且 deploy contract 到 Ethereum 的測試網路 Rinkeby,圖檔和 Metadata 則上傳到 IPFS,再透過測試版本的 OpenSea 來看看成果!

在上一篇 如何以 Python Brownie 開發 DeFi 應用?- 環境準備 我們討論到如何用 Brownie 準備好 Solidity 的開發環境。這次我們將延續上篇,簡單的建立一個 ERC 721 的 contract ,快速走過一輪開發過程。

設定 OpenZeppelin dependency

現在寫 smart contract 已經有很多現成的樣本可以直接採用,他們通常也經過社群的檢視,出問題的機率也比較小。

其中一個較知名的為 OpenZeppelin,可以直接去他們官方的 github 看各種 contract 樣板,以及把這些樣板當作 library 的方式引入。

引入的方式也很簡單,首先在 brownie-config.yaml 中宣告 dependency:

dependencies:
  # - <organization/repo>@<version>
  - OpenZeppelin/[email protected]

這樣就可以了。為了讓後面寫 import 的時候可以少寫一點字,我們可以在 brownie-config.yaml 中做個 remapping:

compiler:
  solc:
    remappings:
      - '@openzeppelin=OpenZeppelin/[email protected]'

之後到 contract 中只要用 @openzepplin 就能直接引入。未來如果 library 升版也不用一一去修改,統一調整 config 即可

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

什麼是 NFT ERC-721?

最常聽到的說法是 ERC-721 相對於 ERC-20 (也就是常常聽到的「幣」)的差異在於每一個 NFT 都是獨一無二不可分割的。這樣說是很白話,但感覺沒說到原理。

如果 ERC-721 從技術上來看,他其實是定義 smart contrace 的 interface,透過實現這些 interface 來完成概念上的 NFT。

從資料上來看,我們把 ERC-721 儲存資料的方法用關聯式資料庫來譬喻,他其實就是一張表,記錄著 token ID 的擁有地址是誰

NFT token IDowner address
10x6973c95F239A833193e1D3692e7D1753936Ae4Da
20x447698d2b749dc0acd37ae821f25fb1ef9e5bea4

就這樣!有沒有很訝異?其實也沒什麼神奇的地方。既然是用 id 對擁有者地址的方式儲存,當然就會是「獨一無二」且「不可分割」。

當然除此之外,還會有其他的表來存一些資訊,其中有一個比較重要的是 tokenURI 的表。

NFT token IDtokenURI
1ipfs://0xB4531a14fd2d92C5026B8EdCA8fAEB3104Bd0a85
2ipfs://QmQrUs9JTM3ZdjYxqmk12dFKWB4su9pAJMNsTRMmRhuT1s

如果沒有給定好 tokenURI 的指向,或是指向的內容設定錯誤,你的 NFT 掛到 opensea 上就會是空白一片,雖然合約存在於區塊鏈上,但肯定不會有價值的! 這部分我們後續會再描述如何設定。

如何以 Python Brownie 開發 DeFi 應用?- 發個 NFT 吧!實現 ERC-721
失敗的 NFT 在 testnet 上,圖片區一片空白

寫第一份 NFT ERC-721 合約吧!

由於我們使用 OpenZeppelin 的樣板來撰寫,ERC-721 制定的介面基本上都已經處理好,我們只需要:

  1. 增加 contract owner
  2. 增加產生 tokenId 的方法

現在以發一個 IKEA 可愛恐龍的 NFT 為例,我們在專案中的 contracts 目錄新增一個檔案「KongLongNFG.sol」,然後將整份合約貼入:

// contracts/KongLongNFT.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract KongLongNFT is ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    address public owner;

    constructor() ERC721("KongLongNFT", "KLG") {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Only the owner can do this");
        _;
    }

    function mintToken(address newOwner, string memory tokenURI)
        public
        onlyOwner
        returns (uint256)
    {
        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(newOwner, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }
}

比想像中簡單很多!這邊講解一下程式內容

  • 一開始宣告 code 的 license,沒有什麼特別的。
  • 緊接著宣告 Solidify 使用的版號,因為不同版本的語法會有差異。這邊我們以 0.8.0 下去寫。
  • 接下來就是 import 寫好的 lib 啦!我們引入了基本的 ERC721.sol
  • 另外引入的 ERC721URIStorage.sol,實現前面提到的 tokenURI 表,讓開發者可以為每個 NFT token 設定完全不一樣的 tokenURI。細節我們後續再討論
  • 引入 Counters.sol,實現如資料庫中能遞增的 Integer unique id
// contracts/KongLongNFT.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
  • 基本的起手式,用 contract 關鍵字宣告你的 smart contract,就像 OOP 的語言用 class 一樣
  • 繼承 ERC721URIStorage,關鍵字是 is
  • 宣告 _tokenIds 和 owner 變數。_tokenIds 的類型是在引入的 Counters.sol 中定義。owner 用來儲存 account 地址,因此使用 address 類型
  • solidity 中如果要呼叫 parent 的建構式,是直接接在合約建構子宣告後面。這邊呼叫 ERC721(),並傳入寫死的 name 和 symbol
  • 在建構式中,將 msg.sender,也就是 deploy contract 的 account 地址,寫入 owner 變數,作為後續實作權限控管使用
contract KongLongNFT is ERC721URIStorage {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    address public owner;

    constructor() ERC721("KongLongNFT", "KLG") {
        owner = msg.sender;
    }
  • solidity 中的 modifier 蠻像 python 中的 decorator。這邊宣告一個 onlyOwner 的 modifier,讓被修飾的函數在執行前會先檢查呼叫者是否為 owner,若非則拋出錯誤,退出 contract
    modifier onlyOwner() {
        require(msg.sender == owner, "Only the owner can do this");
        _;
    }
  • 最後是 mintToken 函數,實作鑄造 NFT 方法
    • 由 public 修飾,可以透過外部直接呼叫
    • 同時由 onlyOwner 的 modifier 修飾,具有呼叫者權限檢查
    • 傳入新鑄造 NFT 的 owner address,以及 NFT 的 tokenURI
    • tokenURI 變數宣告前使用 memory 修飾,memory / storage 的差異可以詳閱這篇文
    • 回傳型態為 uint256
    • 進入方法第一步,先遞增 tokenIds
    • 利用新的 tokenId 和 newOwner 地址來呼叫內部方法 _mint 儲存 NFT record
    • 為此 NFT 存入 tokenURI
    • 回傳最新 NFT 的 ID
    function mintToken(address newOwner, string memory tokenURI)
        public
        onlyOwner
        returns (uint256)
    {
        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(newOwner, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }
}

Compile Smart Contract 檢查語法

我們先 compile 看看語法有沒有問題

$ brownie compile

Brownie v1.17.2 - Python development framework for Ethereum

Project has been compiled. Build artifacts saved at /xxxxxxxx/build/contracts

如果有問題的話,會在 console 列出來有問題的行數

CompilerError: solc returned the following errors:

ParserError: Expected '(' but got identifier
  --> contracts/KongLongNFT.sol:33:14:
   |
33 |     function getCurrentTokenId() public view returns (uint256) {
   |              ^^^^^^^^^^^^^^^^^

到這整個 NFT 的 contract 大致上都差不多了,下一篇我們會分享如何用 python 寫 deploy 的 script,實際把這份合約 deploy 到 local Ganache 測試看看!

延伸閱讀:
如何以 Python Brownie 開發 DeFi 應用?- 環境準備
自己寫 NFT 吧!- Deploy 到 Ganache – 以 Python Brownie 開發
自己寫 NFT 吧!- 準備 Metadata – 以 Python Brownie 開發
自己寫 NFT 吧!- Deploy 到 Rinkeby testnet – 以 Python Brownie 開發

Written by J
雖然大學唸的是生物,但持著興趣與熱情自學,畢業後轉戰硬體工程師,與宅宅工程師們一起過著沒日沒夜的生活,做著台灣最薄的 intel 筆電,要與 macbook air 比拼。 離開後,憑著一股傻勁與朋友創業,再度轉戰軟體工程師,一手扛起前後端、雙平台 app 開發,過程中雖跌跌撞撞,卻也累計不少經驗。 可惜不是那 1% 的成功人士,於是加入其他成功人士的新創公司,專職開發後端。沒想到卻在採前人坑的過程中,拓寬了眼界,得到了深層的領悟。