🎲

オラクルによる乱数取得[超入門]

2022/12/18に公開

これはno plan inc.の Advent Calendar 2022の20日目の記事です。

ちょっと時代遅れかもしれませんが、乱数はDAppの発展には欠かせないパーツでもあるので、今回はオラクルとしても有名なChainlinkとAPI3で乱数を取得してみたいと思います。

まずはとにかく簡単に乱数取得を行いたい。

最も手っ取り早いのはブロックハッシュを用いたものです。
Ethereumは決定論的な環境であるため、Solidityでは乱数生成に使用できる組み込みのエントロピー源を持ちません。そのため以下のようにエントロピー源に近いブロックハッシュを用いてみます。

OnchainRng.sol
function random(uint seed) public view returns (uint) {
	return uint(
		keccak256(block.blockhash(block.number-1), seed)
	);
}

これはユーザー生成のシードで親のブロックハッシュ(block.blockhash(block.number-1))をハッシュ化し、バイト列を取得しています。

問題点

ブロックのマイニングにより予測不能のブロックハッシュが生成されますが、そのブロックハッシュを使おうとした時、現在のブロックのブロックハッシュはそのブロックがマイニングされるまで利用することはできません。故に親のブロックハッシュを用いていますが、これは親のブロックハッシュとシードにアクセスできれば誰でも推測可能であることになります。オンチェーンで乱数を取得したい場合他にもいくつか工夫が考えられますが、例えばマイナーがアプリケーションのユーザーである場合に、マイナーは不都合なブロックハッシュの場合はあえてブロックをブロードキャストしないということが考えられます。結果として、アプリケーションから得られる報酬の期待値とブロック生成による報酬を天秤にかけることになります。

じゃあどうしたらええねん

オンチェーンで乱数を取得する取り組みが他にもあるかもしれませんが、自然な考えの流れとして、オフチェーンから乱数を取得するという方法が残っています。
しかし、オフチェーン値が不偏であることを暗号的に検証しなければ、オフチェーンプロバイダやデータ転送層がその乱数値をオンチェーンに置くことによって、結果が操作される可能性があります。同様に、アプリケーションのユーザーは、乱数が公正に生成され、アプリケーションに届くまでに変更されることなくオンチェーンに持ち込まれたと信頼せざるを得ません。 トラストポイントが発生するということです。

上記の問題を解決するために、Ethereumの外部からエントロピー源を用いる別のオプションとしてChainlinkやAPI3があります。

Chainlink

Chainlink VRFの持つ強みはオフチェーンで生成したランダム値をオンチェーンで検証可能であるという点です。
これによって、開発者がChainlink VRFを採用するメリットは、自分のサービスの安全性をより確実にすることに加え、自社のアーキテクチャの重要な部分が検証可能なランダム性で動作していることをユーザーに証明することができます。

  1. アプリケーションのコントラクトからseedと一緒にrequestを送る。
  2. seedとoracleの秘密鍵をもとにランダム値を作り出し、proofと共にVRFコントラクトに送付
  3. oracleの公開鍵とアプリケーションのseedを用いてオンチェーンでproofを検証
  4. アプリケーションのコントラクトが検証されたランダム値を取得

より詳細な解説はChainlinkの技術解説記事、また実装の元となる提案はこちらの論文で示されています。

Chainlinkによる乱数は現在Chainlink VRF v2で提供されています。
DApp、VRF、Sbscriptionアプリの関係図は以下のようになります。

この構造により、個々のリクエストごとに LINKを送る必要がなくなるため、VRFリクエストのガス代を最大60%削減できるようです。

VRFを扱うコントラクトは以下になります。

RandomNumberConsumer.sol
// SPDX-License-Identifier: MIT
// An example of a consumer contract that relies on a subscription for funding.
pragma solidity ^0.8.7;

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";

/**
 * @title The RandomNumberConsumerV2 contract
 * @notice A contract that gets random values from Chainlink VRF V2
 */
contract RandomNumberConsumerV2 is VRFConsumerBaseV2 {
    VRFCoordinatorV2Interface immutable COORDINATOR;

    // Your subscription ID.
    uint64 immutable s_subscriptionId;

    // The gas lane to use, which specifies the maximum gas price to bump to.
    // For a list of available gas lanes on each network,
    // see https://docs.chain.link/docs/vrf-contracts/#configurations
    bytes32 immutable s_keyHash;

    // Depends on the number of requested values that you want sent to the
    // fulfillRandomWords() function. Storing each word costs about 20,000 gas,
    // so 100,000 is a safe default for this example contract. Test and adjust
    // this limit based on the network that you select, the size of the request,
    // and the processing of the callback request in the fulfillRandomWords()
    // function.
    uint32 constant CALLBACK_GAS_LIMIT = 100000;

    // The default is 3, but you can set this higher.
    uint16 constant REQUEST_CONFIRMATIONS = 3;

    // For this example, retrieve 2 random values in one request.
    // Cannot exceed VRFCoordinatorV2.MAX_NUM_WORDS.
    uint32 constant NUM_WORDS = 2;

    uint256[] public s_randomWords;
    uint256 public s_requestId;
    address s_owner;

    event ReturnedRandomness(uint256[] randomWords);

    /**
     * @notice Constructor inherits VRFConsumerBaseV2
     *
     * @param subscriptionId - the subscription ID that this contract uses for funding requests
     * @param vrfCoordinator - coordinator, check https://docs.chain.link/docs/vrf-contracts/#configurations
     * @param keyHash - the gas lane to use, which specifies the maximum gas price to bump to
     */
    constructor(
        uint64 subscriptionId,
        address vrfCoordinator,
        bytes32 keyHash
    ) VRFConsumerBaseV2(vrfCoordinator) {
        COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
        s_keyHash = keyHash;
        s_owner = msg.sender;
        s_subscriptionId = subscriptionId;
    }

    /**
     * @notice Requests randomness
     * Assumes the subscription is funded sufficiently; "Words" refers to unit of data in Computer Science
     */
    function requestRandomWords() external onlyOwner {
        // Will revert if subscription is not set and funded.
        s_requestId = COORDINATOR.requestRandomWords(
            s_keyHash,
            s_subscriptionId,
            REQUEST_CONFIRMATIONS,
            CALLBACK_GAS_LIMIT,
            NUM_WORDS
        );
    }

    /**
     * @notice Callback function used by VRF Coordinator
     *
     * @param requestId - id of the request
     * @param randomWords - array of random results from VRF Coordinator
     */
    function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords)
        internal
        override
    {
        s_randomWords = randomWords;
        emit ReturnedRandomness(randomWords);
    }

    modifier onlyOwner() {
        require(msg.sender == s_owner);
        _;
    }
}

重要なのは以下の二つの関数です。

  • requestRandomWords(): 指定したパラメータを受け取り、VRFコントラクトにリクエストを送信します。

  • fulfillRandomWords(): 乱数を受信し、コントラクトに保存します。

それでは早速v2のコントラクトを動かしてみましょう。

% git clone https://github.com/smartcontractkit/hardhat-starter-kit/
% cd hardhat-starter-kit
% yarn
% yarn add --dev @nomicfoundation/hardhat-toolbox @nomicfoundation/hardhat-network-helpers @nomicfoundation/hardhat-chai-matchers @nomiclabs/hardhat-ethers @nomiclabs/hardhat-etherscan chai ethers hardhat-gas-reporter solidity-coverage @typechain/hardhat typechain @typechain/ethers-v5 @ethersproject/abi @ethersproject/providers

ネットワーク設定を行います。

cp .env.example .env

.envを任意のプロバイダ、ウォレットに設定してください。また、それに伴いhardhat.config.jsも適宜書き換えてください。

デプロイしたコントラクトを用いて乱数を取得する際にはGoerliのETHとLINKが必要になります。LINKトークンのFaucetはこちらですが、12/8現在無効になっているようでメールで請求する必要があるようです。

VRF Subscription Page へ行き、新しいsubscriptionを作成します。その際にsubscription IDをメモし、helper-hardhat-config.jsファイルのsubscriptionIdとして記入します。

helper-hardhat-config.js
5: {
    // rest of the config
    subscriptionId: "your subscription id"
}

Goerliで行う場合は以下のようにdeployします。

% yarn deploy --network goerli

Nothing to compile
...
Random Number Consumer deployed to 0xa20... on goerli
✨  Done in 340.11s.

サンプルプロジェクトは乱数コントラクトや価格フィードコントラクトも同時にデプロイする設定になっていますが今回扱うのはRandom Number Consumerです。

デプロイできたらsubscriptionページにアクセスして、デプロイされたコントラクトのアドレスを新しいconsumerとして追加します。

これが完了したら、request-random-numberタスクでVRFリクエストを実行できます。

% npx hardhat request-random-number --contract 0xa20... --network goerli
Requesting a random number using VRF consumer contract  0xa20...  on network  goerli
Contract  0xa20...  random number request successfully called. Transaction Hash:  0xf8a...
Run the following to read the returned random number:
yarn hardhat read-random-number --contract 0xa20... --network goerli

乱数のリクエストに成功したら、read-random-numberタスクで結果を見ることができます。

% npx hardhat read-random-number --contract 0xa20... --network goerli
Reading data from VRF contract  0xa20... on network  goerli
Random Numbers are: 46693873649659878806909253441593120099654740051570626242244041920263555013740 and 88490903034082528126442200002254407276424573747504237911719082577634405504760

無事にChainlinkで乱数を取得することができました。

API3

API3そのものの強みとして、API3のデータフィードは、サードパーティのノードオペレータを介さないため、中間業者によるデータの改ざんやサービス拒否攻撃にさらされることはないfirst-party oraclesであるということが挙げられます。 これにより、攻撃対象が小さくなり、sybil attackのリスクを減らすことができます。
しかしながら、乱数生成については例外で外部から取得する必要がありますが、API3はエントロピー源としてQRNG(量子乱数生成器)を採用しています。 技術的な提案はこちらの論文に示されています。乱数生成は量子の世界でも難しく、信頼されたデバイスであれば実装も簡単で乱数生成レートも高いものになりますが、信頼されないデバイスでのDevice-independentなQRNGは実装も困難でレートも出ないというトレードオフがあります。その中で信頼するデバイスをなるべく減らしつつ最小限のトラストポイントで実装しようというのがSemi-device-independentなQRNGで、現在の研究での大きな仕事になります。

生成過程は認めるとして、オンチェーンに持ってくる際の改ざんなどのリスクは考えられますが、現在のQRNGでは有名なAWS Marketplaceでも公開されているAustralian National University (ANU) Quantum Random Numbers APIを用いているため、ここも含めてfirst-partyであるというのがおそらく彼らの主張です。

requesterAirnodeQRNG APIとの関係は以下のようになります。

また、requesterと我々ユーザーの関係は以下のようになります。

スポンサーウォレットを設定します。このウォレットは、Airnodeがリクエストに応答したときにガス料金を支払うために使用されます。

QRNGを扱うコントラクトは以下になります。

QrngExample.sol
//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
import "@api3/airnode-protocol/contracts/rrp/requesters/RrpRequesterV0.sol";

/// @title Example contract that uses Airnode RRP to receive QRNG services
/// @notice This contract is not secure. Do not use it in production. Refer to
/// the contract for more information.
/// @dev See README.md for more information.
contract QrngExample is RrpRequesterV0 {
    event RequestedUint256(bytes32 indexed requestId);
    event ReceivedUint256(bytes32 indexed requestId, uint256 response);
    event RequestedUint256Array(bytes32 indexed requestId, uint256 size);
    event ReceivedUint256Array(bytes32 indexed requestId, uint256[] response);

    // These variables can also be declared as `constant`/`immutable`.
    // However, this would mean that they would not be updatable.
    // Since it is impossible to ensure that a particular Airnode will be
    // indefinitely available, you are recommended to always implement a way
    // to update these parameters.
    address public airnode;
    bytes32 public endpointIdUint256;
    bytes32 public endpointIdUint256Array;
    address public sponsorWallet;

    mapping(bytes32 => bool) public expectingRequestWithIdToBeFulfilled;

    /// @dev RrpRequester sponsors itself, meaning that it can make requests
    /// that will be fulfilled by its sponsor wallet. See the Airnode protocol
    /// docs about sponsorship for more information.
    /// @param _airnodeRrp Airnode RRP contract address
    constructor(address _airnodeRrp) RrpRequesterV0(_airnodeRrp) {}

    /// @notice Sets parameters used in requesting QRNG services
    /// @dev No access control is implemented here for convenience. This is not
    /// secure because it allows the contract to be pointed to an arbitrary
    /// Airnode. Normally, this function should only be callable by the "owner"
    /// or not exist in the first place.
    /// @param _airnode Airnode address
    /// @param _endpointIdUint256 Endpoint ID used to request a `uint256`
    /// @param _endpointIdUint256Array Endpoint ID used to request a `uint256[]`
    /// @param _sponsorWallet Sponsor wallet address
    function setRequestParameters(
        address _airnode,
        bytes32 _endpointIdUint256,
        bytes32 _endpointIdUint256Array,
        address _sponsorWallet
    ) external {
        // Normally, this function should be protected, as in:
        // require(msg.sender == owner, "Sender not owner");
        airnode = _airnode;
        endpointIdUint256 = _endpointIdUint256;
        endpointIdUint256Array = _endpointIdUint256Array;
        sponsorWallet = _sponsorWallet;
    }

    /// @notice Requests a `uint256`
    /// @dev This request will be fulfilled by the contract's sponsor wallet,
    /// which means spamming it may drain the sponsor wallet. Implement
    /// necessary requirements to prevent this, e.g., you can require the user
    /// to pitch in by sending some ETH to the sponsor wallet, you can have
    /// the user use their own sponsor wallet, you can rate-limit users.
    function makeRequestUint256() external {
        bytes32 requestId = airnodeRrp.makeFullRequest(
            airnode,
            endpointIdUint256,
            address(this),
            sponsorWallet,
            address(this),
            this.fulfillUint256.selector,
            ""
        );
        expectingRequestWithIdToBeFulfilled[requestId] = true;
        emit RequestedUint256(requestId);
    }

    /// @notice Called by the Airnode through the AirnodeRrp contract to
    /// fulfill the request
    /// @dev Note the `onlyAirnodeRrp` modifier. You should only accept RRP
    /// fulfillments from this protocol contract. Also note that only
    /// fulfillments for the requests made by this contract are accepted, and
    /// a request cannot be responded to multiple times.
    /// @param requestId Request ID
    /// @param data ABI-encoded response
    function fulfillUint256(bytes32 requestId, bytes calldata data)
        external
        onlyAirnodeRrp
    {
        require(
            expectingRequestWithIdToBeFulfilled[requestId],
            "Request ID not known"
        );
        expectingRequestWithIdToBeFulfilled[requestId] = false;
        uint256 qrngUint256 = abi.decode(data, (uint256));
        // Do what you want with `qrngUint256` here...
        emit ReceivedUint256(requestId, qrngUint256);
    }

    /// @notice Requests a `uint256[]`
    /// @param size Size of the requested array
    function makeRequestUint256Array(uint256 size) external {
        bytes32 requestId = airnodeRrp.makeFullRequest(
            airnode,
            endpointIdUint256Array,
            address(this),
            sponsorWallet,
            address(this),
            this.fulfillUint256Array.selector,
            // Using Airnode ABI to encode the parameters
            abi.encode(bytes32("1u"), bytes32("size"), size)
        );
        expectingRequestWithIdToBeFulfilled[requestId] = true;
        emit RequestedUint256Array(requestId, size);
    }

    /// @notice Called by the Airnode through the AirnodeRrp contract to
    /// fulfill the request
    /// @param requestId Request ID
    /// @param data ABI-encoded response
    function fulfillUint256Array(bytes32 requestId, bytes calldata data)
        external
        onlyAirnodeRrp
    {
        require(
            expectingRequestWithIdToBeFulfilled[requestId],
            "Request ID not known"
        );
        expectingRequestWithIdToBeFulfilled[requestId] = false;
        uint256[] memory qrngUint256Array = abi.decode(data, (uint256[]));
        // Do what you want with `qrngUint256Array` here...
        emit ReceivedUint256Array(requestId, qrngUint256Array);
    }
}

重要なのは以下の二つの関数です。大まかな構造はChainlinkと同じです。

  • makeRequestUint256(): 指定したパラメータを受け取り、AirnodeRrpにリクエストを送信します。
  • fulfillUint256(): 乱数を受信します。

makeRequestUint256Array(),fulfillUint256Array()についてはその名の通り複数の乱数がarrayで返ってくるというだけです。是非試してみてください。

それでは早速サンプルプロジェクトを動かしていきます。

% git clone https://github.com/api3dao/qrng-example.git
% cd qrng-example
% yarn

ネットワーク設定を行います。

% cp credentials.example.json credentials.json

credentials.jsonを任意のプロバイダ、ウォレットに設定してください。
また、12/8現在、QRNGを提供しているANU Quantum Random Numbers側で問題があるようで、乱数を取得することができないため、もう一つのAPI Provider byogを使用してみます。

クローンしたプロジェクトはANU Quantum Random Numbersの設定なので以下を参照しながら、apis.jsonファイルのairnodeとxpubを書き換えます。
https://docs.api3.org/qrng/reference/providers.html#byog-random-numbers

byogに対応したapis.jsonは以下のようになります。

apis.json
{
  "ANU Quantum Random Numbers": {
    "airnode": "0x6238772544f029ecaBfDED4300f13A3c4FE84E1D",
    "endpointIdUint256": "0xfb6d017bb87991b7495f563db3c8cf59ff87b09781947bb1e417006ad7f55a78",
    "endpointIdUint256Array": "0x27cc2713e7f968e4e86ed274a051a5c8aaee9cca66946f23af6f29ecea9704c3",
    "xpub": "xpub6CuDdF9zdWTRuGybJPuZUGnU4suZowMmgu15bjFZT2o6PUtk4Lo78KGJUGBobz3pPKRaN9sLxzj21CMe6StP3zUsd8tWEJPgZBesYBMY7Wo"
  }
}

Goerliで行う場合は以下のようにdeployします。

% NETWORK=goerli yarn deploy

Compiled 6 Solidity files successfully
deploying "QrngExample" (tx: 0x244...)...: deployed at 0x066... with 640930 gas
Deployed QrngExample at 0x066...
Setting request parameters...
Request parameters set
Funding sponsor wallet at 0x124... with 0.1 ETH...
Sponsor wallet funded
✨  Done in 67.23s.

デプロイしたコントラクトに対し、乱数をリクエストします。

% NETWORK=goerli yarn request:uint256

Created a request transaction, waiting for it to be confirmed...
Transaction is confirmed, request ID is 0xd9d...
Waiting for the fulfillment transaction...
Fulfillment is confirmed, random number is 21257430352973636759587215138753309771290158625120922743831921662784103897396
✨  Done in 62.63s.

無事にAPI3で乱数を取得することができました。

感想

ChainlinkのVRF(On-Chain Verifiable Randomness)は素人ながら筋が良さそうと思いました。
API3 QRNGは研究室で動くような系がこのようにSaaSとして扱われていくことにはワクワク感があります。

よかったらTwitterのフォローもお願いします!

no plan株式会社について

Discussion