[區塊鏈] 嘗試實作 NFT 發行白名單校驗機制:off-chain 簽章

之前大班哥實測如何發行 NFT 用的智能合約製作 NFT 發行網站後,初步了解整個 NFT 技術面從無到有的過程,跟著研究下去,發現大家都在探討如何防止科學家/機器人來搶 Mint NFT,而衍伸出了白名單機制,藉以來限制可以 Mint 的人是誰,

不過驗證白名單這件事既需要公平 (確保名單的人都能 Mint ) 且有效率 (Gas 不能太高)的方法,

所以大班哥就開始了白名單校驗機制的研究 – 「off-chain 的數位簽章驗證」。

名詞解釋 Mint: 表示跟智能合約互動鑄造出 NFT

名詞解釋 科學家:指那些在幣圈裡程式能力厲害的人,可以直接寫程式跟智能合約互動

先說說何謂白名單

白名單使用者 Mint NFT 流程

先解一下何謂白名單,又為什麼需要校驗?

白名單就是指早期參與項目的一批人,通常這些人都是投入很多時間跟精力去參與該項目社群 ,而項目方給予這些人優先權去 Mint ,一般來說時間都會是正式販售前。

可以看到 [ 上圖- 流程三 ] ,當白名單使用者可以去官方網站 Mint 的時候,也代表其他人像是科學家/機器人/駭客可以去網站嘗試破解,甚至直接透過程式跟智能合約互動

所以項目方網站跟智能合約要能清楚校驗出誰才是白名單使用者,就會是整個過程非常重要的議題。



那白名單的驗證方法有哪些呢?

參考協助 ZombieClub 發行 NFT 的技術團隊寫的文章,裡面提到的三種白名單校驗方法:

  1. 直接把白名單寫在合約裡:沒效率可能導致 Gas Fee 很貴
  2. Merkel tree 驗證:在新增或刪除 address 都會更動 tree ,開發時需要一直維護,要跑不同的行銷邏輯又可能會多顆 tree
  3. 鏈下 off-chain 發簽章:發簽章的 server 可能會被攻擊,然後要小心 private key 絕對不能上 server

他們團隊表示是用第三種方法,也讓我好奇這種方式「鏈下 off-chain 發簽章」是怎麼運行的,

然後到底又是如何做到能夠防止科學家/機器人,

以下是大班哥自行猜測簡單實作出的方法

白名單校驗流程

整個步驟可以分為 4 步:

  • Step 1 取得白名單地址,產生簽章:要注意此步驟為了資訊安全,需要在本地端動作,千萬部要把私鑰上傳到雲端
  • Step 2 白名單使用者開始 Mint
  • Step 3 透過錢包地址查詢簽章:
  • Step 4 返回簽章,與智能合約互動:


ECDSA 橢圓曲線數位簽章算法

聽到數位簽章後,大班哥就開始好奇,「拿到簽章的人,要怎麼能確保這個簽章是不是當初那個簽章者發的」,

就是使用非對稱式加密 -> ECDSA 橢圓曲線數位簽章算法 (Elliptic Curve Digital Signature Algorithm)

比特幣跟比太幣都是使用 ECDSA 來做驗證,所以之後在合約裡也會看到這樣的演算法來驗證簽名,不過當然不用自己實作,

可以使用 solidity 的 ecrecover() / OpenZeppelin 裡提供的 ECDSA 套件去使用

ECDSA 加密簽章簡單理解

首先產生 256bit 的私鑰,拿著私鑰透過橢圓曲線加密演算法 ECDSA 產出公鑰

而 ECDSA 演算法是方程式 y^(2)=x^(3)-x+1 在上面的點的運算

▼ 在橢圓曲線上,定義一個「加法」,這個加法不是一般的數學加法,

大班哥用以下例子說明,

例如 A + B,為 A 跟 B 兩點連線交 C,在垂直向下交 C ‘ ,所以定義的「加法」是 A + B = C ‘,

▼ 那當 A + A 時該等於多少呢?

可以想成兩點沿著曲線相交靠近,最終變成該點「切線」

▼ 再套用上面橢圓「加法」公式,該點「切線」向右相交,對稱 X 軸後得到 A + A

▼ 以此類推點 「A + A + A」,

就是拿點「A + A」跟 「A」在橢圓曲線上相交的點,再跟 X 軸做對稱就可以得到點「A + A + A」

那為什麼我們一直要計算 A + A + A… 呢

是因為公鑰的產生來源,

假設我們在橢圓曲線選取一點,就叫 A(x, y),

公鑰: 私鑰 K 乘以 A(x, y),然後再做合併,

即是 K次相加的 X 跟 K次相加 Y,要注意加法是要用橢圓曲線的加法,所以等於是在計算 A + A + A …. K 次

所以可以理解公鑰私鑰做 K 次運算,然後最終生成為橢圓曲線函數上的一個點

嘗試實作簽章流程

Step 1 建立錢包:透過 clef 建立新帳戶 & geth 跟區塊鏈互動

以下流程大班哥是參考 官方 Getting Started 教學 整理出來的

安裝 geth

brew tap ethereum/ethereum
brew install ethereum

啟動 Clef,它是一個帳戶管理工具,他會把住要資訊像是密碼等儲存在 keystore,需要在 local 端好好保管好,

▼ 透過 Clef 新增一個 Account 亦即創一個錢包

clef newaccount --keystore geth-tutorial/keystore

可以看到下面 Clef 新增錢包的流程,需要先輸入一個密碼,

要小心密碼要自己先記住,創完後就不會在顯示到 console 上了

提供之後 geth 的街口,指定 rinkeby 測試網 chainId 是 4

clef --keystore geth-tutorial/keystore --configdir geth-tutorial/clef --chainid 4

▼ 啟動 geth ,在 local 端使用 geth 能跟以太鏈測試網 Rinkeby 互動

geth --datadir geth-tutorial --signer=geth-tutorial/clef/clef.ipc --rinkeby --syncmode light --http

啟動後會開始尋找 peers,畫面會一直跳出 「Looking for peers」



Step 2 利用本地端錢包簽名訊息

有了第一步的產生的錢包,就可以利用這個錢包來把訊息產生簽名,以提供之後白名單去 Mint 的時候可以驗證,

那要透過錢包如何產生簽名呢?

可以利用 web3.js 套件的 web3.eth.account.sign() 官方詳細文件可參考

web3.eth.accounts.sign(data, privateKey);

可以看到參數有兩個:

  • data:要被 簽名/sign 的訊息
  • privateKey:該錢包的私鑰

data 的部分,一般來說都是用白名單者的錢包地址,對於之後合約在解簽名時能夠對照是否是白名單使用者

要注意不用自己加上 \x19Ethereum Signed Message:\n” + message.length + message”,此方法已會自動加上

▼ privateKey 這邊大班哥是用 ethkey 這個套件把錢包的私鑰從 keystore 提取出來

ethkey inspect --private test_chain/keystore/UTC--<file name

透過 web3.eth.accounts.sign 簽名後,返回的 signature object,有幾個屬性

其中的 signature ,就是我們需要的

▼ 除了 web3.eth.accounts.sign() 也可以用 web3.eth.sign() 來測試簽章

不過在使用 web3.eth.sign() 透過 metamask 去簽走 RPC 的話要下要 32 byte hash message,要不然會報錯

RPC Error: eth_sign requires 32 byte message hash

解法是用 web3.utils.keccak256 去做 hash 即可

let msg = "0xad183fd88f83822B4ed09C39E39690662D";
let keccakMsg = web3.utils.keccak256(msg);           
let signedMsg = await web3.eth.sign(msg, accounts[0]);

上述動作把所有白名單的產生簽章流程,可以用 node.js 寫成一個 script 批次產生

Step 3 架設 signature API server

產生出了所有白名單者的簽名後,就是要放到 server 上,提供 web3 網站使用者在 mint 的時候,要一起帶過去跟合約互動



Step 4 智能合約驗證的 function 如何檢查簽名

▼ 大班哥在 Solidity 是用 OpenZeppelin 裡面提供的 ECDSA 去把簽名解出來,因為原本就是透過 ECDSA 去加密的

記得要先在開始 import

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

▼ 透過 ECDSA.recover() 來解出 signer,是否是當初發簽名的錢包地址,

require(recoverSigner(signature) == owner(), "Address is not allowlisted");

owner() 表示創建合約的地址,所以可以理解當初合約部署的帳戶是跟發簽名的帳戶是一樣的,要不然錢包地址就要寫死在合約裡

/*
 * Mint BigBenFun NFTs
 */
function mintBigBenFun(uint256 numberOfTokens, bytes memory signature) public payable {
    require(saleIsActive, "Sale must be active");
    require(numberOfTokens <= MAX_MINTS_PER_TXN, "You can only adopt 5 BigBenFun at a time");
    require(totalSupply() + numberOfTokens <= maxTokenSupply, "Purchase would exceed max available BigBenFun");
    require(mintPrice * numberOfTokens <= msg.value, "Ether value sent is not correct");
    require(recoverSigner(signature) == owner(), "Address is not allowlisted");

    for (uint256 i = 0; i < numberOfTokens; i++) {
        uint256 mintIndex = _tokenIdCounter.current() + 1;
        if (mintIndex <= maxTokenSupply) {
            _safeMint(msg.sender, mintIndex);
            _tokenIdCounter.increment();
        }
    }
}

可以看到 bytes32 hashMsg = keccak256(abi.encodePacked(msg.sender));

裡面有 msg.sender 也就是當下白名單 mint 的那個人的錢包地址,

這也是當初 web3.eth.accounts.sign(data, privateKey); 裡 data 塞的訊息那個白名單者的錢包地址

function recoverSigner(bytes memory signature) public view returns(address) {  
    bytes32 hashMsg = keccak256(abi.encodePacked(msg.sender));
    return ECDSA.recover(hashMsg, signature);
}

recoverSigner(signature) == owner() 通過後就表示確定是白名單的使用者

以上步驟就是整個驗證 off-chain 簽章的大致流程



真實案例討論

原本要拿 Zombie 網站來說明,但項目方已經把 sign server 關閉了,

又剛好大班哥最近可以 Mint Planetary Property Association  這個 NFT 專案,就以此當案例

▼ 在 Mint 之前,可以先去看看合約 https://etherscan.io/address/0x99120d128a5f7cb81c318a24fa1f60f66d9777d7#code

果不其然有看到 shuttlepassMint function,需要帶一個 signature

可以看看 function 裡的實作 81 行,isValidSignature(msg.sender, totalMintsAllowed, 3, signature)

▼ 再往下看這個 isValidSignature 寫了些什麼,

發現是需要帶三個參數,分別是 1. mint 的人 address 2. 允許能 Mint 的數量 3. 販售的狀態 4. 簽章 signature

function 裡的行為,可以理解成:

  • Step 1. 把參數帶來的資訊做 keccak256 hash
  • Step 2. 用openzeppelin 提供的 ECDSA.toEthSignedMessageHash() 轉 byte 32
  • Step 3. 最後丟到 recover 解出 sign 的 address
  • Step 4. 判斷是否跟 signerAddress 一樣 (這邊合約有 function 可以改 setSignerAddress() )

▼ 來到網站,可以看到要 Mint 的頁面,連結錢包後有一個 Mint 按鈕,大概率就是觸發溝通合約,

大班哥建議故意產生錯誤,看有沒有在 chrome console 出現 MetaMask – RPC Error: execution reverted: 訊息出現,

再拿訊息反查合約有沒有出現字串,那就八九不離十是跟合約溝通

▼ 如果依照大班哥上面對於 off-chain sign 的機制,應該會 call 一個 API 去要 signature ,

開啟 Chrome inspector 查看後,

發現透過 Network -> Fetch/XHR 都沒看到相關的 request,只有一個拿 mint 總數 & 販售狀態的 request

▼ 這時候大班哥猜想可能項目方已經把 signature 放到網站上了

因為是網站是用 React 做的,可以找到 source code 是放在 /static 底下,是 main.2b179caf.js

▼ 這時候大班哥猜想可能項目方已經把 signature 放到網站上了複製內容再透過 formatter 稍微讓排版好看一點後,可以猜測一些關鍵字搜尋 signature、sign、sig 等等來找看看 sign

果不其然,發現了 signature 在前端

接下來找到 address 對應的 signature 就可以直接與合約互動,而不必一定要透過網站

小結

在實測跑完整個 off-chain 簽章後,之後在操作項目方的網站時,其實都更能了解整個白名單的發行機制與技術,雖然大班哥自己沒有要發行,但這個過程也學到不少區塊鏈相關的知識,也分享大家,感謝你看到這邊 : )



2 則留言

  1. 新手

    大班哥您好
    我想請問:通常說PFP是自動生成(隨機)的意思是主體不變 其他身上的配件分別畫出來就可以自動組成嗎?
    例如Sneaky Vampire Syndicate(SVS)
    還是是要把8888張都全部畫出來 只是在解盲時隨機分配而已這樣?

    • 大班哥

      其實以技術 (Solidity 智能合約) 來說都可以做到,以你的例子來說,那 8888 張可以是早就畫好並指定好編號 1-8888 的 NFT 對應的 asset,只是在盲盒時期都先指向一張盲盒圖,
      也可以是在 Mint 完才生成一張 user 客製化的圖,很多項目甚至把這個功能當作一個 feature,所以就看項目方怎麼設計囉

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。

這個網站採用 Google reCAPTCHA 保護機制,這項服務遵循 Google 隱私權政策服務條款