Solidity 教學: Ethereum Improvement Proposal, EIP & Ethereum Request for Comments, ERC & Token

本篇預計閱讀時間 50 分鐘, 實作時間 30 分鐘。

在開始之前先來思考底下的練習題,閱讀後開始練習。

練習題

  • 練習題 1

    • 如何利用以下程式碼寫出一個 walletOfOwner 函式來調用這個地址擁有的全部 tokenId?

function walletOfOwner(address _owner)
public
view
returns (uint256[] memory)
{
...
return tokenIds;
}
  • 練習題 2

    • 如何利用以下函式與 4 個要求完成這個 mint 函式,"..." 代表需要你填入程式碼的位置?

  function mint(address _to, uint256 _mintAmount) public payable {
uint256 supply = totalSupply() - 5;
  // 當前發行量,這邊 - 5 是因為已經在 Constructor 先鑄造 5 個了
require(...); // 每次必須鑄造超過 0 個
require(...); // 鑄造的數量不可以大於每次最大鑄造數量
require(...); // 鑄造的數量和當前發行量加起來,不可以超過最大總發行量

  for (uint256 i = 0; i < _mintAmount; i++) { // tokenID 從 0 開始while(...){ // 如果這個 NFT 已經鑄造過了
        i++;
    }
    _safeMint(_to, supply + i); 
    // 用迴圈來鑄造
    }
}
  • 練習題 3

    • 如何發行並且傳送給別人一個無法轉移的幣,透過持有這個幣來證明身分?

 

在區塊鏈領域中的大多數公鏈項目都是開源的,所以常會需要社區來共同改進這個項目。以比特幣為例,任何人都可以上 Github 網站 Pull Source Code 來自行修改程式碼。

要移除或增加各種功能並不是由哪一個中心組織或個人決定的,而是由核心團隊、礦工、社區愛好者、用戶等眾多角色構成。

BIP 為比特幣改進建議(Bitcoin Improvement Proposal),為一項用於介紹和著名比特幣各項協議、資訊、特色、改進想法、意義和技術內容的文件。任何人都可以在任何地方提出新的討論,爭取到社區的支持以後以 BIP 模板的方式提交給核心開發者,最後便可透過多重步驟拍板通過(或遭到否決)。

常見的技術內容包含區塊擴容、簽名支付技術、錢包技術等。

圖 14-1 BIP/EIP Improvement Proposal

圖 14-1

EIP(Ethereum Improvement Proposal) 是由任何人發佈在以太坊開源區的建議提案。EIP 和 BIP 一樣可以是各種技術或想法的改進,比如說 ERC、協議改進、開發工具、新特性等等。

ERC (Ethereum Request for Comments) 是將開發規則統一為標準的提案。在提出 EIP 建議後,接受認可後官方會發出相應的 ERC 進行一些細節和問題討論,通常情況下此時 EIP 及 ERC 使用相同編號。

ERC 和 EIP 的編號就僅僅是提案被提出的先後順序決定,並沒有其他的意義。就像我們最常使用的 ERC-20 為第 20 個提出的提案。

官方認可的 ERC 標準有非常多,其中也包含了 ERC-20 (同質化代幣,FT)和 ERC-721(非同質化代幣,NFT)等,均為以太坊上代幣種被的規範標準。

 

ERC-20 Fungible Tokens

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract GLDToken is ERC20 {
    constructor(uint256 initialSupply) public ERC20("Gold", "GLD"){_mint(msg.sender, initialSupply);
    }
}

ERC-20 的功能函數、事件與資訊

ERC-20 在 2015-11-19 由 Fabian Vogelsteller, Vitalik Buterin 提出。ERC-20 為目前以太坊上最多人使用的標準,此標準除了提高 Token 的互換性,還能在 DApp 上面進行同樣的運行。常見的應用有:

  • 穩定幣:與法定貨幣錨定的代幣,發行者可能持有相對應的抵押資產,或儲蓄。

  • 安全代幣:為股權型代幣的一種,可能是有價證券,例如股票、債券或實物資產。

  • 功能型代幣:為服務型代幣的一種,可能是遊戲貨幣、Dapp 燃料、忠誠度積分。

ERC-20 包含六個功能函式、兩個事件以及三個 Token 資訊的函式。

/*【功能函式】--------------------------------------------*/
function totalSupply() public view returns (uint256):
// Token 的總供應量,也就是這個智能合約的總供應量。

function balanceOf(address _owner) public view returns (uint256 balance):
// 可查詢 _owner 地址中的餘額。
// 在以太坊中帳戶都是公開的,只要知道地址就能查詢所有帳戶的餘額。

function transfer(address _to, uint256 _value) public returns (bool success):
// 轉移代幣;發送數量為 _value 的 Token 到地址 _to,觸發 Transfer 事件。

function transferFrom(address _from, address _to, uint256 _value) public returns (bool success):
// 從地址 _from 發送數量為 _value 的通證到地址 _to,觸發 Transfer 事件。

function approve(address _spender, uint256 _value) public returns (bool success):
// 批准代幣轉移;批准 _spender 提取一定數量的金額。

function allowance(address _owner, address _spender) public view returns (uint256 remaining):
// 回傳批准給地址的代幣數量;回報 _spender 從 _owner 提取的金額。

/*【事件函式】--------------------------------------------*/event Transfer(address indexed _from, address indexed _to, uint256 _value):
// 轉移代幣事件;當token被轉移時則會觸發。

event Approval(address indexed _owner, address indexed _spender, uint256 _value):
// 代幣批准觸發事件;成功調用approve方法後則會觸發。

/*【Token 資訊函式】--------------------------------------------*
/function name() public view returns (string):
// 代幣的全名;也就是發行 Token 之名稱

function symbol() public view returns (string):
// 發行Token之代稱,也能稱作代幣縮寫。

function decimals() public view returns (uint8):{}
// 代幣的最小單位數值;設定此Token最多能達到小位數點後多少位數。

 

ERC-721 Non-Fungible Tokens

之前有介紹過 Non-fungible Token (非同質化代幣) 與相關的代幣標準,其實 ERC-721 Token 的敘述就是 NFT。

接下來的內容會著重在程式碼與開發的部分。

NFTs & ERC-721

ERC-721 正式應用為 2018-01-24 由 William Entriken, Dieter Shirley, Jacob Evans, Nastassia Sachs 發行,Dieter Shirley 也是 Axiom Zen 的技術總監,就是第一個使用 ERC-721 作為去中心化遊戲應用 CryptoKitties 的公司。用於處理不可替換資產的另一種以太坊代幣標準。

所有的 NFTs 都會擁有一個 uint256 也就是 256 位元的代號,也就是 tokenId。

所以在每個 ERC-721 的合約之中, 每組 contract address, uint256 tokenId 都是獨一無二的,這種代幣可以通過區塊鏈上的智能合約追蹤,從而形塑數位化的資產。

在 DApp 都有一個 "converter" 可以以 tokenId 為輸入並輸出有趣又特別的圖片。也就是眾所皆知的 NFT 藝術收藏品!

ERC-721 的功能函數、事件與資訊

ERC-721 除了本身兼容了 ERC-20 內的規則,還包含以下的函式:

 

/*【Token 資訊函式】--------------------------------------------*/
function name() external view returns (string _name);

function symbol() external view returns (string _symbol);

function tokenURI(uint256 _tokenId) external view returns (string);
// A distinct Uniform Resource Identifier (URI) for a given asset.

/*【功能函式】--------------------------------------------*/

function totalSupply() external view returns (uint256);
// return A count of valid NFTs tracked by this contract

function tokenByIndex(uint256 _index) external view returns (uint256);
// return The token identifier for the `_index`th NFT,function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256);

// return The token identifier for the `_index`th NFT assigned to `_owner`,

// 每個所有者可以同時擁有一個以上的 NFT。其獨特的 ID 可以識別每一個 NFT,結果可能會變得難以跟蹤 ID。所以合約將這些 ID 存儲在一個數組中,tokenOfOwnerByIndex 函數讓我們從數組中檢索這些訊息。

function balanceOf(address _owner) external view returns (uint256);

// return The number of NFTs owned by `_owner`, possibly zerofunction ownerOf(uint256 _tokenId) external view returns (address); 
// Find the owner of an NFT

function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;

function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
// Transfers the ownership of an NFT from one address to another address

function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
// Transfer ownership of an NFT

function approve(address _approved, uint256 _tokenId) external payable;

function setApprovalForAll(address _operator, bool _approved) external;

function getApproved(uint256 _tokenId) external view returns (address);

function isApprovedForAll(address _owner, address _operator) external view returns (bool);

/*【事件函式】--------------------------------------------*/

event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);

event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);

event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

 

可以看到兼容 ERC-20 的函式有幾個,但在 ERC-721 中也出現新的函式。開發者讓智能合約能夠支持記錄及轉移代幣的所有權。

此外在 ERC-721 中 approve 以及 transfer 都被重新設計。

Approval 就像是我們允許某個人可以管理我們的代幣,像是 transfer 或者 sell。而 Operator 則是 approval 全部代幣。通常是讓第三方管理我們的代幣,例如 OpenSea

ERC-721 Non-Fungible Token Standard | ethereum.org

EIP-721: ERC-721 Non-Fungible Token Standard

就我們所知,所謂的鏈上數據通常都是一串的數字或者是文字,但藝術品通常都有其相當的外觀,那這些「圖像」甚至「影片」到底是怎麼儲存,並且出現在我們眼前的呢?

tokenURI 元數據方法

首先要知道,如果我們把 NFT 的 MetaData 以及相關數據全部儲存在鏈上,那將耗費極大的成本。當然如果這張圖片的價值超過所需要付出的 Gas,也許有人會決定把所有資訊放上鏈。不過如果不要把 JPEG 放在鏈上的話,那 NFT 的 JPEG 會出現在哪,以何種方式「安全地」呈現呢?

我們接續前面介紹的 ERC-721 API 與內置函數,

interface ERC721 {
    event Transfer(...);
    event Approval(...);
    event ApprovalForAll(...);function balanceOf(...);
    function ownerOf(...);
    function safeTransferFrom(...);
    function safeTransferFrom(...);
    function transferFrom(...);
    function approve(...);
    function setApprovalForAll(...);
    function getApproved(...);
    function isApprovedForAll(...);
}
interface ERC721Metadata {
    function name(...);
    function symbol(...);
    function tokenURI(...);
}

我們要聚焦在 tokenURI 之上。

function tokenURI(uint256 _tokenId) external view returns (string);
// A distinct Uniform Resource Identifier (URI) for a given asset.

觀察之後會發現 NFT 在鏈上其實是儲存成一個 256-bit 的非負整數

如果我們去到任何 Etherscan 上的 NFT 合約查看 Contract Source Code,像是我們查看有名的 BAYC 可以發現以下程式碼。

 

圖 14-2 BAYC 合約地址 Contract Address

圖 14-2 BAYC 合約地址 Contract Address

/**
* @title ERC721 Non-Fungible Token Standard basic implementation
* @dhttps://eips.ethereum.org/EIPS/eip-721
*/

contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable {
    ...
    // Optional mapping for token URIs
    mapping (uint256 => string) private _tokenURIs;
    ...

    /**
    * @dev See {IERC721Metadata-tokenURI}.*/
    function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
    require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token");

    string memory _tokenURI = _tokenURIs[tokenId];
    string memory base = baseURI();

    // If there is no base URI, return the token URI.
    if (bytes(base).length == 0) {return _tokenURI;
    }

    // If both are set, concatenate the baseURI and tokenURI (via abi.encodePacked).
    if (bytes(_tokenURI).length > 0) {return string(abi.encodePacked(base, _tokenURI));}
    // If there is a baseURI but no tokenURI, concatenate the tokenID to the baseURI.
    return string(abi.encodePacked(base, tokenId.toString()));}

也就是說當我們查看 tokenURI 的時候他執行一個內部的 map 函數,有點像 python 的 dict 雜湊,並不是一個從合約外部來 hash 的函數。

圖 14-3 BAYC Token Contract

圖 14-3 BAYC Token Contract

此時我們去 Read Contract 的部分查看 tokenURI ,並輸入任意的 tokenId (uint256) 即可查看回傳的 string 是長什麼樣子。我在 Bored Ape Yacht Club 的合約底下查詢 9946 的猴子後,回傳的是:

string :  ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/9946

圖片到底是儲存在哪並且顯示的呢?

其實也就是在 IPFS 上!

這裡先把字串中的 ipfs:// 改成 https://ipfs.io/ipfs/ 就可以來到一個神祕的地方!

https://ipfs.io/ipfs/QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/9946

(可能某些公家機關或者網路會擋 IPFS 的訪問,可以切換成手機熱點來避免)

我們可以發現這是一個 JSON 的檔案格式,裡面儲存的就是這個 NFT 該要有的資料,也就是 NFT 的 MetaData

{"image":"ipfs://QmQn9BnEMHrbqApJ59wDnFqEjtoZE6pKy4LoxFhWA7MKPw","attributes":[{"trait_type":"Hat","value":"Sea Captain's Hat"},{"trait_type":"Mouth","value":"Phoneme Vuh"},{"trait_type":"Eyes","value":"Sad"},{"trait_type":"Background","value":"New Punk Blue"},{"trait_type":"Fur","value":"Golden Brown"}]}

最重要的是我們會看到的 "image",如果我們使用一樣的方法把字串中的 ipfs:// 改成 https://ipfs.io/ipfs/ 後,就可以看見圖片!

https://ipfs.io/ipfs/QmQn9BnEMHrbqApJ59wDnFqEjtoZE6pKy4LoxFhWA7MKPw

​ERC-1155 Semi-Fungible Tokens

半同質性代幣(Semi-Fungiable Token)

半同質性代幣(Semi-fungiable token)是藉由 ERC-1155 (Multi Token Standard) 標準發行的代幣。

ERC-1155 標準允許在同一個合約底下,進行同質性代幣和非同質性代幣的流通和互換,也就是說代幣可以在使用前後或不同情況下,有不同的代幣性質。如此在 ERC-721 代幣的架構之下,ERC-1155 甚至可以擁有供給量的特性,還能使用 ERC-20 和 ERC-721 也包含的函式們。

ERC-1155 並不像是我們在硬幣上刻上自己的名子,而更像是可贖回票券、ICOs、NFT 的綜合應用。同時有別於 ERC-721 代幣的 MetaData 是固定的,每個 ERC-1155 代幣都可以擁有自己格式的 MetaData,藉此增加了發行方想要達到的彈性。

ERC-1155(Multi Token Standard)

ERC-1155 讓交易變得更為便利,不只有轉換同質化和非同質化代幣,還提供了批量轉帳、批量查額、批量授權等功能。這對一些 NFT 項目和 Game-Fi 來說是非常便利的,因為他們就可以在一次合約調用的情況下做到批量功能來節省開銷和提升效率。整個以太坊經濟系統變可達到更規模化的程度。

ERC-1155 代幣標準特性:

  • ERC-1155 是一個同時擁有同質化、半同質化、非同質化代幣的合約介面(Contract Interface)

  • ERC-1155 可同時使用 ERC-20 和 ERC-721 的函式

  • 利用 ERC-1155 代幣標準發行的代幣可同時擁有同質化、半同質化、非同質化代幣的性質,也可以有不同的價值、MetaData格式

此種代幣標準發行的合約可以透過在合約內轉換代幣來節省 Gas,過往我們需要互換不同的代幣必須使用另外一個智能合約建置的 DApp 或相關客戶端來執行這項動作,但在同一個合約內與不同類型的代幣互動便可以省掉上述繁瑣又開銷的步驟。

ERC-1155 的功能函數、事件與資訊

以下範例參考自 ethereum.org

  • Batch Transfers

批次轉移就像是我們平常使用的 ERC-20 transfer,只不過為批量轉移複數個代幣類別。

// ERC-20
function transferFrom(address from, address to, uint256 value) external returns (bool);

// ERC-1155
function safeBatchTransferFrom(
    address _from,
    address _to,
    uint256[] calldata _ids,
    uint256[] calldata _values,
    bytes calldata _data
) external;

ERC-1155 相較於其他代幣標準最大的不同便是多了陣列的參數項,以此來達到批量動作的目的。

  • Batch Balance

批量查額同樣也對應到了 ERC-20 的 balanceOf()

// ERC-20
function balanceOf(address owner) external view returns (uint256);

// ERC-1155
function balanceOfBatch(
    address[] calldata _owners,
    uint256[] calldata _ids
) external view returns (uint256[] memory);

我們可以藉由單一的函式呼叫來對多個 balance 的調用,一樣也是透過陣列的參數項來呼叫我們需要的結果。舉例來說輸入:

  • _ids=[3, 6, 13]

  • _owners=[0xbeef…, 0x1337…, 0x1111…]

回傳值如下:

[
    balanceOf(0xbeef...),
    balanceOf(0x1337...),
    balanceOf(0x1111...)
]
  • Batch Approval

這裡的 approvals 和 ERC-20 稍微不同,我們需要自己定義要 approve 或不 approve 的 operator,而不是直接定義一個要 approve 的數量。

// ERC-1155
function setApprovalForAll(
    address _operator,
    bool _approved
) external;

function isApprovedForAll(
    address _owner,
    address _operator
) external view returns (bool);
  • Receive Hook

function onERC1155BatchReceived(
    address _operator,
    address _from,
    uint256[] calldata _ids,
    uint256[] calldata _values,
    bytes calldata _data
) external returns(bytes4);

這個 hook function 必須回傳一個已經被定義過的 Function Signature,型態為 bytes4 的值:

bytes4(keccak256("onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)"))

只要合約接收到了這個值,這個合約就是同意這筆交易並已經了解如何管理 ERC-1155 代幣。

ERC-20 Example - YFI

藉由分析成熟項目的合約發行實例,來對發行代幣有完整的理解。

Yearn Finance 又稱做 yEarn。是一種以太坊協定,用戶可以藉由借貸和交易服務來將其加密資產的收益最佳化。如同 DeFi 的定義一樣,Yearn Finance 只透過程式碼來提供服務,免除了傳統金融上的中間人、保管人角色。

當前 Yearn Finance 內最知名又複雜的策略為 yVault。yVault會利用自動執行的平台程式碼追蹤、處理多項 DeFi項目,並得到最高的收益。同時 yVault 被設計成開放式的合約,其他協議也可以在其之上衍伸出更多元創新的服務。更詳細的技術內容可詳見

Source Code

用戶可以透過一些方法獲得 YFI 代幣,來得到治理 Yearn Finance 的權限。YFI 代幣和 Yearn Finance 的 protocol 合約內容並不是同一個合約,而是利用新的合約發行代幣作治理用。這使得這個 DeFi 協議朝著去中心化更進一步,包含人員聘用、收益池策略、治理方式、手續費結構等都可以經過社群決策而轉向、贊成、否決等。

Source Code

來看 Yearn Finance 發行 YFI 時所發布佈署 ERC-20 的合約。

/** 
*Submitted for verification at Etherscan.io on 2020-07-17
*/
pragma solidity ^0.5.16;

interface IERC20 {    
    function totalSupply() external view returns (uint);    
    function balanceOf(address account) external view returns (uint);    
    function transfer(address recipient, uint amount) external returns (bool);    
    function allowance(address owner, address spender) external view returns (uint);    
    function approve(address spender, uint amount) external returns (bool);    
    function transferFrom(address sender, address recipient, uint amount) external returns (bool);    
    event Transfer(address indexed from, address indexed to, uint value);    
    event Approval(address indexed owner, address indexed spender, uint value);
}

首先是宣告版本,由於 Yearn Finance 已經上線一段時間,版本不是最新的 0.8 是正常的。

導入 IERC-20,也就是 ERC-20 的 Interface,如同我們之前在 Interface 章節敘述的一樣,這裡的接口定義是讓欲發行代幣符合 ERC-20 的形式,藉此達到規格化。

contract Context {    
    constructor () internal { }    
    // solhint-disable-previous-line no-empty-blocks    
    function _msgSender() internal view returns (address payable) {        return msg.sender;    
    }
}

這邊使用 Context 合約的原因可能為了是模組化,因為在 Solidity 中語法迭代的非常快速,舉例來說:now 被淘汰而現在是使用 block.timestamp。

在這裡的例子如同以前會使用的 tx.origin 到此時此刻的 msg.sender,未來 msg.sender 是否會被淘汰是不得而知的。

contract ERC20Detailed is IERC20 {    
    string private _name;    
    string private _symbol;    
    uint8 private _decimals;    

    constructor (string memory name, string memory symbol, uint8 decimals) public {        
    _name = name;        
    _symbol = symbol;        
    _decimals = decimals;    
    }    
    function name() public view returns (string memory) {
        return _name;    
    }    
    function symbol() public view returns (string memory) {
        return _symbol;    
    }    
    function decimals() public view returns (uint8) {        return _decimals;    
    }
}

ERC20Detailed 這個合約定義了一個代幣合約最基本的資料,包含 name:代幣名稱、symbol:代幣縮寫、decimal:小數點後最多有幾個位數,通常(最多)為 18。

模組化的道理和前述段落差不多。

contract ERC20 is Context, IERC20 {    
    using SafeMath for uint; 
   
    mapping (address => uint) private _balances;    

    mapping (address => mapping (address => uint)) private _allowances;    

    uint private _totalSupply;    
    function totalSupply() public view returns (uint) {
        return _totalSupply;
    }    
    function balanceOf(address account) public view returns (uint) {
        return _balances[account];
    }    
    function transfer(address recipient, uint amount) public returns (bool) {
        _transfer(msgSender(), recipient, amount);
        return true;
    }
    function allowance(address owner, address spender) public view returns (uint) {
        return _allowances[owner][spender];
    }
    function approve(address spender, uint amount) public returns (bool) {
        _approve(_msgSender(), spender, amount);
        return true;    }
    function transferFrom(address sender, address recipient, uint amount) public returns (bool) {
        _transfer(sender, recipient, amount);
        _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance"));
        return true;
    }    function increaseAllowance(address spender, uint addedValue) public returns (bool) {
        _approve(_msgSender(), spender, _allowances[_msgSender()][spender].add(addedValue));
        return true;    
}
    function decreaseAllowance(address spender, uint subtractedValue) public returns (bool) {
       _approve(msgSender(), spender, allowances[msgSender()][spender].sub(subtractedValue, "ERC20: decreased allowance below zero"));
        return true;
    }
    function _transfer(address sender, address recipient, uint amount) internal {
        require(sender != address(0), "ERC20: transfer from the zero address");
        require(recipient != address(0), "ERC20: transfer to the zero address");

        _balances[sender] = balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
        _balances[recipient] = balances[recipient].add(amount);
        emit Transfer(sender, recipient, amount);
    }
    function _mint(address account, uint amount) internal {
        require(account != address(0), "ERC20: mint to the zero address");

        _totalSupply = _totalSupply.add(amount);
        _balances[account] = _balances[account].add(amount);
        emit Transfer(address(0), account, amount);
    }
    function _burn(address account, uint amount) internal {
        require(account != address(0), "ERC20: burn from the zero address");
        _balances[account] = _balances[account].sub(amount, "ERC20: burn amount exceeds balance");
        _totalSupply = _totalSupply.sub(amount);
        emit Transfer(account, address(0), amount);
    }
    function _approve(address owner, address spender, uint amount) internal {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");
        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }
}

ERC-20(這邊指的是上述程式碼中的 Contract 物件) 裡面定義了 IERC-20 的函式內容(body)。實際上在 ERC-20 內部定義相關函式時,實作 body 的內容和我們之前練習的錢包練習題很像,都是定義了每個錢包的 balance 或 allowances 的資料結構,並對其進行四則運算或其他行為。

library SafeMath {
    function add(uint a, uint b) internal pure returns (uint) {
        uint c = a + b;
        require(c >= a, "SafeMath: addition overflow");
        return c;
    }
    function sub(uint a, uint b) internal pure returns (uint) {        return sub(a, b, "SafeMath: subtraction overflow");
    }
    function sub(uint a, uint b, string memory errorMessage) internal pure returns (uint) {
        require(b <= a, errorMessage);
        uint c = a - b;
        return c;    }
    function mul(uint a, uint b) internal pure returns (uint) {        if (a == 0) {
            return 0;
        }
        uint c = a * b;
        require(c / a == b, "SafeMath: multiplication overflow");
        return c;
    }
    function div(uint a, uint b) internal pure returns (uint) {
        return div(a, b, "SafeMath: division by zero");
    }
    function div(uint a, uint b, string memory errorMessage) internal pure returns (uint) {
        // Solidity only automatically asserts when dividing by 0
        require(b > 0, errorMessage);
        uint c = a / b;
        return c;
    }
}
library Address {
    function isContract(address account) internal view returns (bool) {
        bytes32 codehash;
        bytes32 accountHash = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470;
        // solhint-disable-next-line no-inline-assembly        assembly { codehash := extcodehash(account) }
        return (codehash != 0x0 && codehash != accountHash);
    }
}

library SafeERC20 {
    using SafeMath for uint;
    using Address for address;
    function safeTransfer(IERC20 token, address to, uint value) internal {
        callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));
    }

    function safeTransferFrom(IERC20 token, address from, address to, uint value) internal {
        callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value));
    }
    function safeApprove(IERC20 token, address spender, uint value) internal {
        require((value  0) || (token.allowance(address(this), spender)  0),
            "SafeERC20: approve from non-zero to non-zero allowance"        );
        callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value));    }
    function callOptionalReturn(IERC20 token, bytes memory data) private {
        require(address(token).isContract(), "SafeERC20: call to non-contract");
        // solhint-disable-next-line avoid-low-level-calls        (bool success, bytes memory returndata) = address(token).call(data);
        require(success, "SafeERC20: low-level call failed");
        if (returndata.length > 0) { // Return data is optional            // solhint-disable-next-line max-line-length
            require(abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");
        }
    }
}

在 Solidity 0.8 以前溢位的問題不會報錯,所以導入了 OpenZeppelin 的 SafeMath 函式庫來進行安全的計算。另外還有 Address 還有 SafeERC20 來確保各種行為不會出錯。

contract YFI is ERC20, ERC20Detailed {
  using SafeERC20 for IERC20;
  using Address for address;
  using SafeMath for uint;

  address public governance;
  mapping (address => bool) public minters;
  constructor () public ERC20Detailed("yearn.finance", "YFI", 18) {
      governance = msg.sender;
  }
  function mint(address account, uint amount) public {
      require(minters[msg.sender], "!minter");
      _mint(account, amount);
  }

  function setGovernance(address _governance) public {
      require(msg.sender == governance, "!governance");
      governance = _governance;
  }

  function addMinter(address _minter) public {
      require(msg.sender == governance, "!governance");
      minters[_minter] = true;
  }

  function removeMinter(address _minter) public {
      require(msg.sender == governance, "!governance");
      minters[_minter] = false;
  }
}

最後將要發行的代幣合約繼承以上合約物件之後,把 constructor 需要的參數傳入。

除此之外這裡面還有定義 minters 這個資料結構,還有相關只供管理者使用的函式,能夠對這個資料結構進行修正。

ERC-721 Example - BAYC

說到最知名的 NFT 項目,莫過於無聊猿遊艇俱樂部(Board Ape Yacht Club, BAYC)了。BAYC 由一萬個隨機產生、獨一無二的猿猴組成。其中由於隨機性的不同特徵,導致其稀有度差異,進而使某些猿猴更為值錢。

BAYC 除了原先的一萬隻猿猴之外,後來還有空投血清能夠將自己的猿猴轉為變種猿猴,能夠鑄造柴犬(Club Dog)可以當作寵物等等的擴充功能,使得無聊猿的宇宙更為擴張和有趣。

除此之外在無聊猿的俱樂部裏面還有變種遊戲機(Mutant Arcade)和廁所(Bathroom)兩種遊戲。

接下來就來看一下除了導入 OpenZeppelin 以外最主要的 Source Code 吧!

contract BoredApeYachtClub is ERC721, Ownable {
    using SafeMath for uint256;
    string public BAYC_PROVENANCE = "";
    uint256 public startingIndexBlock;
    uint256 public startingIndex;
    uint256 public constant apePrice = 80000000000000000;
 //0.08 ETH
    uint public constant maxApePurchase = 20;
    uint256 public MAX_APES;
    bool public saleIsActive = false;
    uint256 public REVEAL_TIMESTAMP;

    constructor(string memory name, string memory symbol, uint256 maxNftSupply, uint256 saleStart) ERC721(name, symbol) {
        MAX_APES = maxNftSupply;
        REVEAL_TIMESTAMP = saleStart + (86400 * 9);
    }

宣告合約的時候繼承了兩個合約,一是 ERC721 代表這個 NFT 項目會和其他 NFT 項目使用同一個接口,以此在不同的 DApp 中可以有相同格式。Ownable 則是定義了訪問函式的權限,最為著名的便是 onlyOwner。

進到合約之後一開始便是定義相關的常數。包含鑄造一個無聊猿的價格,單次鑄造最大的數量,總供給數,當前是否可銷售,開盒時間點。

之後的建構子裡面除了把最大供給和代幣名稱補上之外,還設定了揭露時間為開賣後的九天,不過這邊如果使用已經計算好的結果也許能省下一點點 Gas。

    function withdraw() public onlyOwner {
        uint balance = address(this).balance;
        msg.sender.transfer(balance);
    }

這個函式可以把合約裡面的所有錢都提出來,想當然爾這個函式得多加一個 onlyOwner 的 modifier 加以限制權限。

    /**
     * Set some Bored Apes aside
     */
    function reserveApes() public onlyOwner {

        uint supply = totalSupply();

        uint i;

        for (i = 0; i < 30; i++) {

            _safeMint(msg.sender, supply + i);

        }

    }

這是一個只供擁有者鑄造的函式,一次可以鑄造三十個。需要注意的是在 safeMint 的函式中 tokenID 是不可重複的,所以才會選擇 supply + i 的形式來避開過往已經鑄造過的編號。而 safeMint 的實作部分是在 ERC-721 中。

不過這裡潛藏一個問題就是並沒有像是一般的鑄造函式一樣使用 require() 來規避超過 MAX_APES 的風險,也就是說如果現在無聊猿的創作者開始創作新的猴子,就可以在按下這個按鈕之後以只付 Gas 的成本再鑄造三十隻。

 

/**
     * DM Gargamel in Discord that you're standing right behind him.
     */
    function setRevealTimestamp(uint256 revealTimeStamp) public onlyOwner {
        REVEAL_TIMESTAMP = revealTimeStamp;
    }

 

設定揭示的時間。

/*
    * Pause sale if active, make active if paused
    */    function flipSaleState() public onlyOwner {
        saleIsActive = !saleIsActive;
    }

此函式可將販售狀態進行切換,如果啟動按下就關閉,如果是暫停按下就啟動。

    function setBaseURI(string memory baseURI) public onlyOwner {
        _setBaseURI(baseURI);
    }

對應到 MetaData 存放的 IPFS 分散式資料庫網址。

    /**
    * Mints Bored Apes
    */
    function mintApe(uint numberOfTokens) public payable {
        require(saleIsActive, "Sale must be active to mint Ape");
        require(numberOfTokens <= maxApePurchase, "Can only mint 20 tokens at a time");
        require(totalSupply().add(numberOfTokens) <= MAX_APES, "Purchase would exceed max supply of Apes");
        require(apePrice.mul(numberOfTokens) <= msg.value, "Ether value sent is not correct");
        for(uint i = 0; i < numberOfTokens; i++) {
            uint mintIndex = totalSupply();
            if (totalSupply() < MAX_APES) {
                _safeMint(msg.sender, mintIndex);
            }
        }
        // If we haven't set the starting index and this is either 1) the last saleable token or 2) the first token to be sold after the end of pre-sale, set the starting index block
        if (startingIndexBlock 0 && (totalSupply() MAX_APES || block.timestamp >= REVEAL_TIMESTAMP)) {
            startingIndexBlock = block.number;
        }
    }

   鑄造函式,使用迴圈一個一個鑄造。而最後合約會檢查是不是已經全部都賣完了,再利用當前的 block number 可以讓合約獲得在區塊鏈上最接近隨機的隨機數。

這時 startingIndexBlock 這個數是拿來做什麼的呢?

    /**
     * Set the starting index for the collection
     */
    function setStartingIndex() public {
        require(startingIndex == 0, "Starting index is already set");
        require(startingIndexBlock != 0, "Starting index block must be set");

       startingIndex = uint(blockhash(startingIndexBlock)) % MAX_APES;
        // Just a sanity case in the worst case if this function is called late (EVM only stores last 256 block hashes)

        if (block.number.sub(startingIndexBlock) > 255) {            startingIndex = uint(blockhash(block.number - 1)) % MAX_APES;
        }
        // Prevent default sequence
        if (startingIndex == 0) {            startingIndex = startingIndex.add(1);
        }
    }

 使用了鑄造函式中的隨機數來決定 startingIndex 後,我們可以在這個函式發現。

/**
     * Set the starting index block for the collection, essentially unblocking
     * setting starting index
     */
    function emergencySetStartingIndexBlock() public onlyOwner {        require(startingIndex == 0, "Starting index is already set");
        startingIndexBlock = block.number;
    }
}

 如果有人在錯誤的時間點導致 startingIndexBlock 也錯了,那還可以使用這個函式將錯誤的值調回正常的 block.number。

   /*
        * Set provenance once it's calculated
    */
    function setProvenanceHash(string memory provenanceHash) public onlyOwner {
        BAYC_PROVENANCE = provenanceHash;
    }

需要注意的是這個 PROVENANCE 是可以被 owner 更改的。

關於 PROVENANCE 的以下解釋取自於:

BORED APE YACHT CLUB PROVENANCE RECORD

經過 BAYC 官方的說明之後我們可以對這個奇怪的隨機數略知一二,畢竟他在合約裡面並沒有被使用到,反而還有緊急情況的相關應對措施。

圖 14-4 BAYC Provenance Record

圖 14-4 BAYC PROVENANCE RECORD

官方希望可以隨機產生這些代幣,使得沒人可以知道他們的 ID 順序會什麼,因為我們都知道所有圖片是存在 IPFS 中而且任何人都是可以訪問的,也就是說只要一有人開始 mint,就可以推算出稀有的圖片會是在之後第幾個。

但這個計畫並沒有被實作在合約中,也沒有發揮作用。

 

練習題解答

  • 練習題解答 1

    • 如何利用以下程式碼寫出一個 walletOfOwner 函式來調用這個地址擁有的全部 tokenId?

 

function walletOfOwner(address _owner)
   public
    view
    returns (uint256[] memory)
 {

  uint256 ownerTokenCount = balanceOf(_owner);
  uint256[] memory tokenIds = new uint256[](ownerTokenCount); for (uint256 i; i < ownerTokenCount; i++) {

tokenIds[i] = tokenOfOwnerByIndex(_owner, i); }

return tokenIds;
 }
  • 練習題解答 2

    • 如何利用以下函式與 4 個要求完成這個 mint 函式,"..." 代表需要你填入程式碼的位置?

 

 

  function mint(address _to, uint256 _mintAmount) public payable {
uint256 supply = totalSupply() - 5;// 當前發行量,
require(_mintAmount > 0); // 每次必須鑄造超過 0 個
require(_mintAmount <= maxMintAmount); // 鑄造的數量不可以大於每次最大鑄造數量
require(supply + _mintAmount <= maxSupply); // 鑄造的數量和當前發行量加起來,不可以超過最大總發行量

  for (uint256 i = 0; i < _mintAmount; i++) { // tokenID 從 0 開始while(_exist(supply + i)){ 
// 如果現在 tokenID 已經存在就鑄造下一個,這情況只會發生在 creator 自訂
        i++;
    }
    _safeMint(_to, supply + i); 
    // 用迴圈來鑄造
    }
}
  • 練習題解答 3

    • 如何發行並且傳送給別人一個無法轉移的幣,透過持有這個幣來證明身分。

當我們在繼承了 ERC-20 的主合約裡面寫上 mint 的時候,不要直接提供給消費者繼承來的 mint(),而是需要自己寫一個 myTokenMint() 來給 DApp或 Client 端做使用。

如同 ERC-20 的合約在 OpenZeppelin 原理一樣,我們可能想要在主合約裡面建立持有名單, 也就是所謂的 WhiteList mapping。使用了 myTokenMint() 的人都會被同時加入到白名單裡面。

在 transfer() 的時候也必須根據上述原理來做修改。

然而,如此做只能限定不是在你白名單裡面的人無法獲得代幣。也就是說如果有一個人他曾經在客戶端 myTokenMint() 鑄造代幣,他也可以從 myTokentransfer() 獲得別人給他的代幣。因為這是同質化代幣的定義:我的此類 型物質,可以換到任何相同類型的物質,而其價值、意義皆不變。

如果希望在代幣上刻上名字或是其他資訊,我們需要選擇 ERC-721 或ERC- 1155 鑄造的 NFT 非同質化代幣,藉此來區分同類型的代幣具有不同的性質。