Chainlink VRF 是行業領先的安全隨機數生成器(RNG),為智能合約和鏈下系統提供可驗證且防篡改的隨機數來源。本文作者 Xing 則以開盲盒為例詳解 ChainLink 的 VRF 服務是如何保證隨機性以及可驗證性的。

原文:ChainLink VRF 技術分享之 Zombie 開盲

作者:Xing  丨 Web3Caff 經授權發布

前言

Zombie NFT 終於開盲盒了,與別的大多數 NFT 項目開盲盒方式不同,Zombie 採用了 ChainLink VRF 技術作為開盲盒的隨機數,鄙人開了 4 只,全是普通 Zombie,雖然是挺有意思的開盲盒體驗,但還是決定花點時間研究下 VRF 隨機數的工作機制,看看究竟為什麼我還是沒有開到 1/1 的 Zombie?

4 Zombies

第一次聽到 VRF(Verifiable Random Function)是一個大佬私信問我,那時我第一次聽說 VRF,當時簡單查了下,只知道是 ChainLink 提供了一個隨機數服務,但是沒仔細研究過 ChainLink 是如何實現可驗證隨機數的機制的。目前在網上查找的資料,也沒有一篇文章能將 VFT 的工作機制描述清楚的,所以只能是嗑源碼了。看完源碼後發現確實是比較複雜,文章也不太好描述清楚,所以本人也嘗試一下用簡單的語言結合 Zombie 給大家做個分享。

就目前我所知道的大多數 NFT 項目,實際上並不是真正意義上的開 “盲盒”,當大家都 mint 完了之後,實際上項目方就已經知道所有人擁有的 tokenId 了。而開盲盒的時候項目方完全可以將稀有屬性的 NFT 分配給指定人,只需要簡單修改下 metadata 的文件名,然後再將這些文件上傳到 IPFS 網絡中,最後操作 setBaseURI 就行。(關於這部分的細節可以參考我這篇文章

為什麼需要 on-chain 隨機數

技術人員都知道,隨機數的產生需要一個 seed,這個 seed 的隨機性越高,那麼產生的隨機數就越可靠。電腦上的隨機數的 seed 可以是當前系統時間(以納秒為單位,1 納秒=10 的負 9 次方)再加上其他的一些隨機參數,例如網卡 MAC 地址、進程的 PID 等一起作為隨機數種子,再通過一些固定算法就可以生成一個隨機數了。

那如何能真正實現開盲盒,讓這些 NFT 能夠真正隨機的分配給用戶,並且可以被證明無法被人為地操控?這就需要在鏈上能找到一個隨機性較強的 seed,通過算法計算出隨機數,通過這個隨機數給 NFT 指定 metadata 就能實現真正意義上的開盲盒了。

所以生成隨機數只要找到一個隨機性強的 seed 就好。鄙人的紅包項目,也面臨著這個問題,就是當用戶打開一個紅包的時候,可以從紅包中領取一個隨機金額。我合約裡的 seed 是這樣做的:

function _random(uint256 remainMoney, uint remainCount) private view returns (uint256) {   return uint256(keccak256(abi.encode(block.timestamp + block.difficulty + block.number))) % (remainMoney / remainCount * 2) + 1;}

可以看到,紅包隨機數的 seed 用到了 block.timestamp(區塊時間戳),block.difficulty(區塊難度),block.number(區塊高度)。這三個數據是在開紅包這筆交易被打包進區塊的時候由礦工生成的,所以用了這三個鏈上的未知數據作為了 seed,用於領取紅包金額的隨機數計算。

如果是發紅包這種小金額的應用場景問題不大,我這篇文章裡也提過這個問題,因為礦工作惡成本還是很高的,收益比不划算。

但如果是涉及金額較大的 defi 合約,這樣設計隨機數生成就有問題了。比如一個彩票合約,如果開獎號碼的 seed 還是上面幾個參數,加上獎池金額的吸引力足夠大,那麼一個擁有大量算力的礦池就有可能故意修改上述參數以達到控制開獎號碼的目的。

結合 Zombie 開盲盒的過程,VRF 大致的機制流程和邏輯分享以下:

基本流程

  1. 大家開盲盒的時候,首先會調用 Zombie NFT 的合約裡的開盲盒方法(合約未上傳,暫時不知道方法名),Zombie NFT 合約中開盲盒的方法會去調用 ChainLink 的 Coordinator 合約中的 requestRandomWords 方法,向 ChainLink 的 VRF 服務發起一個隨機數的申請請求
  2. ChainLink 的 Coordinator 合約收到 Zombie 合約的隨機數申請請求後,會根據該隨機數請求的參數計算 requestId 和 preSeed,同時向區塊鏈上發送含有此次隨機數請求相關參數的 eventLog
  3. ChainLink 的 Oracle 節點會在鏈下監聽到此次隨機數申請的 Event Log 並拿到相關參數,然後會用自己的私鑰對此次的隨機數請求生成一個證明(Proof),並將該 Proof 提交回鏈上 ChainLink 的 Coordinator 合約中
  4. ChainLink 的 Coordinator 合約會校驗 Proof 的合法性,如果鏈下節點提交的 Proof 合法性被 ChainLink 的 Coordinator 合約校驗通過,則會利用這個 Proof 中的一個加密參數(proof.gamma)作為此次隨機數請求的 seed,並生成隨機數
  5. ChainLink 的 Coordinator 合約用生成的隨機數作為參數,又會去調用 Zombie 合約中預留好的 fulfillRandomWords 方法,然後 Zombie 合約將收到的隨機數計算並分配用戶某個 NFT

以上就是整個 ChainLink VRF 生成的大概過程,上述過程中第 1,2 步是在鏈上的合約完成的,然後第 3 步是鏈下的 Oracle 節點處理,完成處理後又接著調用鏈上合約,接著第 4,5 步又回到了鏈上完成處理。所以 Zombie 開盲盒的時間比較長,就是因為背後完成了兩次合約的交互(中間還等待了一點確認區塊時間)。

鏈下 oracle 監聽 Coordinator 發出的 Event Log

其實 Chain Link price feed 獲取鏈下交易價格的流程類似:鏈上通過 event log 廣播出來所需要獲取的鏈下數據,鏈下節點根據 event log 中的數據請求,獲取到相應鏈下數據後並重新將該數據設置到鏈上。

如何保證鏈下節點不作惡,只能設置真實的鏈下數據到鏈上是一個很複雜的事情,因為數據上鍊前,需要嚴格保證鏈下數據是真實可靠的。大家可以想像一下,如果是一個 Defi 合約,需要獲取 ETH/USDT 交易對的鏈下數據價格來做借貸訂單的交割之類的操作, 這個鏈下數據的價格設置多少會涉及到大量錢的結算,所以這個設置的價格必須是無法作惡、無法作假並且是真實可靠的。如何保證這個鏈下價格是真實可靠的,ChainLink 有一整套機制來實現一個去中心化的鏈下節點校驗,具體機制與鄙人 Terra 網絡及穩定幣經濟模型簡介  這篇文章中介紹的大同小異,感興趣的可以看看 Terra 網絡是如何獲取鏈下 USD/USDT 的真實價格的。

不過 ChainLink VRF 的隨機數獲取的流程雖然與其 price feed 的獲取流程一樣,但是如何保證所取得的隨機數不可預測,又與獲取 price feed 的機制不一樣了。

隨機數 seed 是如何生成以及驗證的?

VRF 名字是 Verifiable Random Function,那:

  • 隨機數的不可預測性
  • 隨機數的不可預測性可以被驗證

這是 VRF 要解決的兩個關鍵問題,這裡簡單描述下 VRF 如何保證隨機性以及其是可驗證的:

  1. Coordinator 合約根據隨機數請求的參數,會先通過一個固定算法生成一個 preSeed,同時記錄下當前這筆交易的 blockNumber,然後將 preSeed 作為 Event Log 內容髮送至以太坊上
  2. ChainLink Oracle 監控到 Event Log 後,從 Event Log 中能夠拿到上一步生成的 preSeed,同時還可以拿到上一步交易所在區塊的 blockHash(特別注意:blockHash 只有該 block 被礦工打包之後才可以拿到,在合約中是無法拿到當前區塊的 blockHash 的,只能拿到 blockNumber,所以這也是上一步 Coordinator 合約中無法記錄當前區塊的 blockHash,只能記錄 blockNumber 的原因)
  3. 在等待指定數量的區塊確認之後,ChainLink Oracle 用自己的私鑰將上述包括 preSeed、blockHash 在內的參數去生成 Proof,並將 Proof 提交回 Coordinator 合約中
  4. 這時 Coordinator 合約會去驗證這個 Proof,因為第一步 Coordinator 合約中記錄下了當時那筆的 blockNumber,所以這時合約裡就可以用 blockHash(blockNumber) 這個函數獲取歷史區塊的 blockHash 了,blockHash 再加上自己所生成的 preSeed,就可以對 ChainLink Oracle 提交的 Proof 進行驗證了
  5. 如果驗證通過,就可以利用 Proof 中一個無法篡改的參數(proof.gamma)作為這個隨機數的 seed。如果驗證不通過,那麼這次 Oracle 提交的 Proof 交易就會被回滾,這樣 Oracle 節點就無法獲取獎勵。

所以只要使用 Chain Link 的 VRF 服務,要么你拿不到隨機數,只要你能拿到就代表一定是被驗證過的且不可預測的隨機數。

因為 Coordinator 合約中會記錄這次隨機數請求的 BlockNumber 以及自己生成好的 preSeed,在該請求被打包好並且發送 log event 後,Oracle 節點才可以拿到 BlockNumber 對應的 BlockHash 以及 preSeed,並且 Oracle 節點生成的 Proof 中又包含有 BlockHash 及 preSeed 參數,所以 Coordinator 合約中就可以用自己記錄的這兩個數值去校驗 Oracle 提交的 Proof 是否正確(校驗的時候合約才能根據記錄的 BlockNumber 拿到 BlockHash)。

VRF 之所以能產生不可預測的隨機數 seed,主要是因為採用了兩個未知因素:

  • BlockHash(在區塊被礦工打包確認前是未知的,被打包之後才能確定)
  • ChainLink Oracle 提交的 proof(提交 Proof 對應的私鑰是未知)

隨機數的生成如果只有 BlockHash 作為 seed 來源,那麼礦工可能作惡;如果只有 ChainLink Oracle 的 proof 作為來源,那麼 Oracle 節點可能作惡。所以 ChainLink VRF 巧妙的結合了這兩個未知來源,Oracle 節點用其私鑰構建的 Proof 中包含了 BlockHash 及 preSeed 這兩個參數,保證了 Proof 的不可預測性,同時將不可預測的 Proof 中的 gamma 參數作為了隨機數的 seed,變相地也就保證了隨機數 seed 的不可預測性了。

所以開不到 1/1 的 Zombie,主要原因是我運氣太差了,哈哈。

// VRFCoordinatorV2.sol// 将 Coordinator 中记录的 preSeed 与 blockHash 生成 actualSeeduint256 actualSeed = uint256(keccak256(abi.encodePacked(proof.seed, blockHash)));// 将 Oracle 节点提交的 Proof 去验证合约中记录的这个 actualSeedrandomness = VRF.randomValueFromVRFProof(proof, actualSeed); // Reverts on failure // VRF.sol// 验证 Proof 成功后,返回 proof.gamma 作为随机数的 seedfunction randomValueFromVRFProof(Proof memory proof, uint256 seed) internal view returns (uint256 output) {    verifyVRFProof(      proof.pk,      proof.gamma,      proof.c,      proof.s,      seed,      proof.uWitness,      proof.cGammaWitness,      proof.sHashWitness,      proof.zInv    );    output = uint256(keccak256(abi.encode(VRF_RANDOM_OUTPUT_HASH_PREFIX, proof.gamma))); }

以上是簡單描述的 VRF 機制如何保證隨機數不可預測以及如何驗證的過程,詳細的工作機制感興趣的話可以參考 ChainLink 合約中的代碼,主要在 VRF.solVRFCoordinator.sol 中。

當然使用 VRF 還有註冊、付費等操作,包括 VRF 對 Oracle 節點也有激勵懲罰機制,這裡就不展開討論了。

關於真隨機

就上述 VRF 的工作機制,我記得 ZombieClub 開展過一個討論,題目是 “世界上存不存在真隨機?”。

當時我的回答是 “這其實是一個物理問題”,就是計算機上產生的一切因子作為隨機數 seed 的話,只要刨根問底都是可以被預測的,即便是上述 VRF 的方案。包括像風、潮汐、天體運動等等這些物理現象,實際上目前都能被公式給推導出來,所以這些自然現像也無法作為真隨機的 seed。

所以什麼是目前人類無法推導計算出來的呢?實際上我也不知道,各位可以發散思考下。

所以隨機性是個相對概念,目前並沒有絕對的真隨機,所以採用什麼隨機數的方案需要根據你的項目的資金大小和對隨機性的容忍度來確定。

例如鄙人紅包 DAPP 中獲取隨機金額的算法就沒有那麼隨機,因為用到的都是鏈上數據。但是我覺得這個沒關係,因為紅包 DAPP 的應用就是小金額的贈予,所以相對於收益作惡成本實在是太高了,不會有礦工為了那一點紅包的錢去作惡。

同樣對於 Zombie 這次使用 VRF 進行開盲盒,從技術上來考量的話,鄙人覺得也無必要。因為如果有礦工作惡的話,收益無外乎就是幾隻屬性稀有的 NFT,相比起他們為作惡這事付出的成本以及正常挖礦帶來的收益,實在是可以忽略不計。但如果考慮到 Zombie 團隊對整個項目的品質要求,以及採用高成本開盲盒方案所帶來的市場影響力的話,也許在 Marketing 方面會有很大的話題性以及區分度。

關於 gasLimit

這裡再提一下 Zombie 公售時,很多朋友遭遇 out of gas 的問題而損失所有 gas。這個問題的根源因為合約還未上傳,所以無法確定。目前能確定就是公售和預售都使用了合約中的同一個方法(鄙人這篇推特中有提到),在同一個方法中的執行路徑不同,導致使用的 gas limit 不同,實際上公售和預售分成兩個方法就好了。

在 TeaHouse 的 AMA 中,項目方人員解釋到為什麼不把公售和預售在合約中分為兩個方法實現的原因大概是:“因為會有 CDN 緩存,擔心用戶在公售時搶 mint 還有緩存,本來應該調用合約中公售方法的時候,因為緩存的原因可能會去調用合約中預售的方法去了。”

當時 AMA 聽到一個外國哥們建議 Zombie 將用戶損失的 gas 返還,當時我點了贊成的表情,並且舉手想討論一下是否有更好的方案可以避免這個損失。當時我想說的是:如果在合約中將公售和預售分為兩個方法,同時前端網頁上也寫好兩個公售和預售方法的調用實現,並且在簽名 API 返回的結果中加入一個 type 來標識此時是公售還是預售,如果 type 是公售類型的話,前端拿到這個結果就調用前端之前寫好的公售方法就好了,這樣也不用擔心 CDN 緩存導致問題。

在寫合約裡方法實現的時候,敏感的執行盡量不要依賴外部因素(例如 block.timestamp),並且不要使用不確定執行次數的循環,否則外部因素變化會導致執行路徑不同,那預估的 gas limit 就會不一樣,從而有可能導致用戶的 gas 損失。

這次開盲盒之前,我其實還挺擔心 out of gas 問題的,因為 ChainLink 設置一個隨機數回到合約後,這個隨機數對應的 NFT 可能被別人開了,這時合約裡就得在循環裡去找下一個數,一直找到未被開的那個 NFT 號碼為止,這就又出現了之前說的不確定執行次數的循環,當循環次數過多可能就又會導致 out of gas 問題,雖然回調的 gas 是由 Zombie 團隊支付了,但是用戶付的開盒請求的 gas 又白付了。

但還好,Zombie 開盲盒過程沒有出現這個問題,我能想到的一個方案是先將所有候選 NFT 號碼作為 list 存入合約,每次計算的隨機數範圍就是這個 list 的長度,開一次盲盒就用該隨機數作為 index 去 remove 一個 NFT 的號碼,這個方法部署合約成本比較高,因為需要先存一個 list 到合約中,但可以避免掉上面那種通過不定長的循環去找剩餘可用 NFT 號碼方案了。

最近 Pak 的 Metamorphosis 系列 NFT 在公售時也遇到了 out of gas 問題,原因主要也是因為使用了不定長的循環,我這篇推文有詳細描述,合約開發者可以注意一下。

關於技術方案的確定及選用,肯定是項目團隊基於條件深思熟慮後得到的,實際上 TeaHouse 已經做的很好了,包括科學家的防範,包括公開 mint 時候的流暢程度。雖然有一點公售時 out of gas 的瑕疵,但是就像樂哥說的,希望大家都繼續向前看吧。

最後

祝愿 Zombie 越來越好!

免責聲明:作為區塊鏈信息平台,本站所發布文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。文章內的信息僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。