大量節省 Gas Fee 的 ERC721A NFT 智能合約

  • FrankFrank
  • /
  • 19 分鐘閱讀
  • /
  • Jan 23, 2022
  • /
  • - views

有人說以太坊的 gas fee, 動輒幾十,幾百美金,這似乎是一道避不過的坑。 參與 NFT 的鑄造亦是如此,mint 一個 NFT,少說也要幾十美金上下,如果一次 mint 多過一個 NFT,gas 的消耗更是成倍上漲。

目前流通中的 NFT 項目,大多數都是基於 OpenZeppelin 的 ERC721 實現版本來進行。 稍微解釋一下, ERC721 是一個標準,這個標準規定了作為一個 ERC721 的智能合約必須做到什麼事情。 實現這個標準有很多不同的做法(implementation), 只要智能合約能夠滿足ERC721的標準要求,則被視為是一個有效的 ERC721 智能合約。而 OpenZeppelin 的 ERC721 實現,則是目前市面上最廣為使用的一個 ERC721 實現。

因此, OpenZeppelin 的 ERC721 實現,當然不是 ERC721 的唯一做法,你,我,都可以自己去實現 ERC721 標準。 今天要介紹的 ERC721a,就是一個新的 ERC721 實現,對比 OpenZeppelin 的 ERC721 實現, 其 mint NFT 特別是 mint 多個 NFT 所需要的 gas 成倍減少。

ERC721a 實現是由 Azuki 研發的新的 ERC721 實現。這個 ERC721a,已經運用到了他們發行的 Azuki NFT Collection 中。根據他們的網站介紹,鑄造一個 NFT 所消耗的 gas fee,相比 OZ ERC721,節省了一倍還多,如果是一次鑄造 5 個,節省的 gas 甚至達到 7 倍。

換算成美金,鑄造一個 NFT 節省 $82 美金,鑄造 5 個則節省驚人的 $650 美金。

光看沒用,自己實際測試一下才可以。 我對比 OZ ERC721 和 Azuki ERC721a 做了實際測試,結果如下:

鑄造1個 NFT:

ERC721 Gas 消耗: 229,528 https://rinkeby.etherscan.io/tx/0xe8f0ee22f0d1f027f7c9a11db5088fe91c1ab63b0d2bb25694901e5175b96d12

ERC721A Gas 消耗: 156,382 https://rinkeby.etherscan.io/tx/0xa8062804800150b03ba1172ceee64d4b3d6da9720f254d595a67bfdac6752aab

鑄造4個 NFT:

721 Gas 消耗: 559,900 https://rinkeby.etherscan.io/tx/0x7db57fd064723326140558dfa84757535c309e9576965db132a6d0d19cd85ef9

721A Gas 消耗: 126,906 https://rinkeby.etherscan.io/tx/0x3013fb6ba02c86086e27a2f167ce56ed87fc6cde97b8b304f9e73ac9e225e729

明顯見到, ERC721a 在 gas 的用量上明顯節省很多。

Why

Gas 節省這麼多,Why?

這裡要再說說另外一個接口 ERC721Enumerable。 為了讓我們能夠更容易的讀取 ERC721 NFT 智能合約關於發行總量,持有者等情況,大多數 NFT 智能合約都會實現另外一個標準,即 ERC721Enumerable,這個標準要求智能合約提供以下功能:

totalSupply(): 該函數返回合約發行的未銷毀的 NFT 總數。此外,每個 token 都需要有一個不是 address(0) 的有效持有者地址。
tokenByIndex(): 該函數返回指定位置的 NFT 的 tokenId。
tokenOfOwnerByIndex(): 該函數返回指定的地址所擁有的 NFT token 中特定索引位置的 NFT 的 tokenId。

上面的解釋聽起來比較拗口,簡單講就是要求智能合約需要保存總共發行了多少 NFT,發行的次序,多少沒有被銷毀,持有者的情況等等。聽起來比較簡單,但深入想想,若是要實現這些功能則需要數據存儲,而在區塊鏈上存儲數據則需要花費金錢,這也是為什麼鑄造一個 NFT 需要花費大量 gas 的原因之一。

ERC721a 做了什麼優化?

首先 ERC721a 對 ERC721Enumerable 中的實現做了優化,去除了一些不必要的存儲。

我們看看 totalSupply() 這個例子:

OZ ERC721Enumerable:

// Array with all token ids, used for enumeration
uint256[] private _allTokens;

/**
  * @dev See {IERC721Enumerable-totalSupply}.
  */
function totalSupply() public view virtual override returns (uint256) {
   return _allTokens.length;
}

Azuki ERC721a:

uint256 private currentIndex = 0;

/**
  * @dev See {IERC721Enumerable-totalSupply}.
  */
function totalSupply() public view override returns (uint256) {
    return currentIndex;
}

可以看到相比 OZ ERC721Enumerable, Azuki ERC721a 沒有使用昂貴的 array 存儲空間來保存 allTokens, 而是直接用 currentIndex 來返回(之所以能這樣做是因為 Azuki ERC721a 的所有 tokenID 都是從 0 開始,逐個增長)。

然後,對於 mint function,Azuki ERC721a 也做了優化。

OZ ERC721 的實現中並沒有提供批量鑄造的功能,換句話說如果我們要鑄造4個 NFT,那就必須循環調用 mint 4 次。因此這也就是為什麼 OZ ERC721 智能合約一次性鑄造 N 個 NFT 的 gas 消耗會隨著 N 的增加而成比例增加的原因。

對於這個問題, Azuki ERC721a 做出了優化,_safeMint 提供了 quantity 參數, 因此我們要鑄造多個 NFT,只需要傳入需要鑄造的數量,一次調用就可以完成。

function _safeMint(address to, uint256 quantity) internal {
    _safeMint(to, quantity, "");
}

不要小看這個參數的加入, 假設小明要鑄造 4 個 NFT, 如果用 OZ 的實現,我們要循環調用 _safeMint 4 次, 而每次 _safeMint 都會去更新小明擁有的 NFT 總數,當前總共發行的 NFT 總數等資料。所以為了把小明擁有的 NFT 總數從 0 變到4,OZ ERC721 會循環四次, 從 0 增加到 1, 再從 1 增加到2, 然後從2 增加到3,最後從 3 增加到 4。 而每次區塊鏈資料的更新,必然需要消耗 gas。 而 Azuki ERC721a ,則是直接將小明擁有的 NFT 總數從 0 變到4,這樣只需更新區塊鏈一次,即可達到效果,節省了3次不必要的更新。

function _safeMint(
    address to,
    uint256 quantity,
    bytes memory _data
) internal {
    uint256 startTokenId = currentIndex;
    
    ...

    AddressData memory addressData = _addressData[to];
    _addressData[to] = AddressData(
        addressData.balance + uint128(quantity),
        addressData.numberMinted + uint128(quantity)
    );
    _ownerships[startTokenId] = TokenOwnership(to, uint64(block.timestamp));

    uint256 updatedIndex = startTokenId;

    for (uint256 i = 0; i < quantity; i++) {
        emit Transfer(address(0), to, updatedIndex);
        require(
            _checkOnERC721Received(address(0), to, updatedIndex, _data),
            "ERC721A: transfer to non ERC721Receiver implementer"
        );
        updatedIndex++;
    }

    currentIndex = updatedIndex;
    _afterTokenTransfers(address(0), to, startTokenId, quantity);
}

**最後,對於 ownerOf() 這個 function,Azuki ERC721a 也做了優化。 **

假設小明鑄造了 4 個 NFT,然後小強又鑄造了 4 個 NFT,OZ 的 ERC721 會將 8 個 NFT 每一個都標記各自的持有者,也就是會存儲 8 個持有者資料。 而 Azuki ERC721a ,只會在第一個 NFT 標記持有者為小明,第五個 NFT 標記持有者為小強。而其餘的 NFT 則不標記。這樣就可以節約大量的存儲空間。

那我們怎麼知道中間的沒有標記持有者的 NFT 是屬於誰?

其實不難,因為 tokenId 是連續增長,所以只要找到比這個 tokenId 小的最接近持有者,即可以認為該持有者亦擁有當前的 NFT。

問題

總體來說,經過多次測試,我覺得 Azuki ERC721a 的確可以節省大量的 gas 成本,特別是一次過鑄造多個 NFT ,其花費的 gas 和鑄造一個 NFT 相差不大。

然而 Azuki ERC721a 也不是沒有問題。

因為 Azuki ERC721a 中的主要邏輯都和 tokenId 必須要保持連續為基礎,所以目前的 Azuki ERC721a 並沒有提供 burn token 的功能。所以如果要使用類似銷毀 NFT 的功能,Azuki ERC721a 則不適用。 而且,如果你需要 NFT tokenId 不是連續增長,Azuki ERC721a 也不適用。

另外,偶然還發現 Azuki ERC721a 部署到 polygon mainnet 中後,如果一次過鑄造超過 4 個NFT,則該組 NFT 在 OpenSea 上無法看到(在 polygon scan 上看到智能合約的執行完全正常, 所有NFT,持有者資料都正確無誤)。

例如 這個 #16 號 NFT, 在 OpenSea 上顯示 Not Found, 而這個 #22 號 NFT,則可以正常顯示。因為我一次過鑄造了 14-18 號 NFT,超過了 4 個, 因此 14-18 號 NFT 都無法正常顯示, 而 19-22 號 NFT 則因為沒有超過 4 個,則可以正常顯示。** 奇怪的是,同樣的智能合約部署在 Rinkeby 測試網則完全沒有問題**, 所以我也不清楚究竟是 OpenSea 的問題還是 Polygon 的問題。

關於這個問題我已經聯絡了官方開發者,等待他們的回覆中。