如何更優雅的實現 NFT 智能合約的白名單功能

  • FrankFrank
  • /
  • 7 分鐘閱讀
  • /
  • Oct 29, 2021
  • /
  • - views

如果玩過 NFT,相信你對「白名單」制度不會陌生。NFT公開發售之前,都會讓一小部分被授權的地址,可以提前購買。因為能夠保證取得購買資格,無需與其他人瘋搶,所以往往大家都非常想擠進這個白名單中去。

本文不討論 marketing 的策略,而是說說 NFT 智能合約如何更優雅的處理白名單。

聰明的你肯定想到,可以用一個 mapping,來儲存所有 whitelist 的地址,當 presale 時檢查這個 mapping 中是否有這個地址即可。

//SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/access/Ownable.sol";

contract Whitelist is Ownable {
    mapping (address => bool) userAddr;

    function addToWhitelist (address[] calldata users) external onlyOwner {
        for (uint i = 0; i < users.length; i++) {
            userAddr[users[i]] = true;
        }
    }

    function preSale() external payable 
    {
        require(whiteList[msg.sender] == true, "STOP: not in whitelist");
        ...
    }

}

當然,這個方法絕對沒錯。很多 NFT 專案都是使用這個方法。 可是,這種的做法的一大問題就是成本太貴。 我做了一個實驗,假如按照 gas price 89 gwei 計算, 一次過儲存 1,000 個白名單地址,消耗 502,774 gwei gas, 需要支付 2.06 ETH,約合 67,885 港幣的費用。如果分開多次存儲,花費更高。

當然如果你「不差錢」,那麼就可以不要再看下去了。但如果不捨得這六萬元,我們可以看看有沒有更好的方法。

答案當然是有的。我們看看下面一段 solidity 程式:

   function presale(
        bytes memory _ticket, // 伺服器發出的「票據」
        bytes memory _signature // 伺服器發出的「簽名」
    ) public payable {
         
        // 如果你想的話,我們可以在智能合約中檢查「票據」是否被使用過。
        require(!_ticketUsed[_ticket], "FRANK: ticket has already been used");

        // 驗證「票據」和「簽名」是否有效
        require(
            isAuthorized(
                msg.sender,
                _ticket,
                _signature,
                signerAddress
            ),
            "FRANK: ticket is invalid"
        );
    
        _mint();
    }

相信聰明的你看完後已經有點頭緒了。 這個做法就是引入了一個「票據」和「簽名」的概念。當用戶在你的網頁上 mint NFT 時,不僅僅是直接同智能合約互動,而是需要預先從 web 伺服器憑藉自己的地址,取得一個授權,這個授權包括一個「票據」和一個「簽名」。

從伺服器的角度來看,伺服器保管有一個簽名私鑰,可以根據用戶的地址,加上一個隨機生成的有意義或無意義數據,即「票據」,組合後進行數位簽署,從而得到一個「簽名」。

然後,用戶可以憑藉「票據」和「簽名」,向智能合約發起購買請求。當智能合約收到購買請求後,會對「票據」和「簽名」進行認證。

認證的步驟,我們會使用 solidity 的 ecrecover 方法。ecrecover 可以根據「簽名」,得出簽名者的公鑰地址。因此,在知道簽名者的公鑰地址的前提下,我們就可以透過核對這個地址,來判斷「簽名」是否由簽名者發出。

我們附帶一個「票據」,則某種程度上是為了防止「回放攻擊」( replay attack )。我們可以在智能合約中檢查該 「票據」是否已經被使用過,從而執行不同的邏輯。

  function isAuthorized(
    address sender, // 發起 trx 人的地址;
    bytes memory ticket, // 伺服器發出的「票據」
    bytes memory signature, // 伺服器發出的「簽名」
    address signerAddress // 簽名人的地址
  ) private pure returns (bool) {
    bytes32 hash = keccak256(abi.encodePacked(sender, ticket));
    return signerAddress == hash.recover(signature);
  }

這種簽名認證的方法,好處非常明顯。我們完全不需要在智能合約中花費大量成本存儲任何白名單地址,就可以優雅的做到白名單效果,而且也可以隨時增加或減少白名單的數量。

不僅如此,使用這個方法,我們亦可以讓智能合約能夠輕易做到一些邏輯驗證。比如,一個購物的智能合約,需要驗證傳入的 ETH 是否和商品價格符合,一個做法是在智能合約中存儲一個商品ID和價格的列表,每當交易時進行比對。而使用簽名認證,則可以使用上述相同邏輯,透過傳入一個「簽名」,智能合約則可以驗證數據的真確性,而無需在智能合約中保存海量昂貴數據。