Chainlink VRF Secure Integration Tips: Specifications

Greetings dear readers!

We continue our series of instructive articles with some special recommendations for both developers and auditors using ChainLink VRF integration.

In the beginning, we would like to express our heartfelt gratitude to the Chainlink VRF designers, community, everyone who supports it, the authors of all resource materials, and, of course, our in-team auditors who have assisted us by providing much-needed information and breaking the veil of secrecy!

Original Article:

In this article, we also plan to go over the background of the most recent Chainlink VRF V2 and compare it to its earlier V1 iteration.

You will also find a list of tools and research for self-study, and we strongly recommend that you read it separately for better understanding! By the way, here are some vacant slots so if your project needs an audit — feel free to write to us, visit our public reports page here!

Let’s get in touch: gm@pessimistic.io!


Following the tips below can significantly improve the security of your project’s integration!

  • Use requestId to match randomness requests with their fulfillment in order.

  • Choose a safe block confirmation time, which will vary between blockchains. In theory, the following attack can happen: the miner/validator notices that Chainlink sent randomness→ they do not like this randomness (it is not profitable)→ they change the blockchain so that the original randomnessRequest got into another block (or the blockHash became different in that block). Because of this, the randomness is now different (can’t make it to be the desired randomness, they can just re-roll the randomness). It also is worth noting that the larger the confirmation block, the more difficult the theoretically possible attack.

  • Do not ever re-request randomness! Suppose there is logic in the contract that allows you to re-roll randomness in case the Chainlink does not respond after N blocks. The point is that if there is such logic, the Chainlink Oracle (VRF service provider, the one who generates random) can abuse it with not responding to the request and re-rolling a new random number. And also, if fulfillRandomWords makes a call to other addresses (for example, through a callback in safeTransferFrom), then the user can also re-roll it making a revert in a contract.

  • The fulfillRandomWords function must not revert. If your fulfillRandomWords() implementation reverts, the VRF service will not attempt to call it a second time. Make sure your contract logic does not revert. In the previous check this aspect is described as with re-request logic, the user can re-roll randomness.

  • A contract that uses Chainlink VRF must be inherited from the underlying VRFConsumer classes. (VRFConsumerBaseV2 for subscription, VRFv2WrapperConsumer for direct funding). There is a built-in check that rawFulfillRandomWords is called by the Chainlink contract.

  • Don’t accept bids/bets/inputs after you have made a randomness request. In theory, a transaction from a Chainlink could be frontrunned (see: Awesome MEV Resources & MEV101) when the transaction appears in a mempool and the randomness is known, accordingly.

  • The fulfillRandomWords should not consume too much gas, the gas that is specified in the requestRandomWords may not be enough, and this randomness request will be lost — keep this in mind!

V1 vs V2

Docs & Developer Resources:

Tl;dr for unique V2 features:

1. State-dependent Randomness:

function fulfillRandomness(bytes32 requestId, uint256 randomness)
        internal override {
        Sacrifice storage currentSacrifice = ActiveSacrifices[requestId];
        if (currentSacrifice.status != 1) {
            return;
        }
        currentSacrifice.status = 2;
        currentSacrifice.randomness = randomness;
    }

function remainingShadowcornEggs() 
    public view
        returns (uint256[3] memory, uint256)
    {
        uint256[3] memory remainingShadowcornEggsByType;
        uint256 totalRemainingShadowcornEggs;
        for (uint256 i = 0; i < 3; i++) {
            uint256 availableCapacity = maxAmount[i]- 
          shadowcornContract.poolSupply(ShadowcornPoolIds[i]);
            totalRemainingShadowcornEggs += availableCapacity;
            remainingShadowcornEggsByType[i] = availableCapacity;
        }

        return (remainingShadowcornEggsByType, totalRemainingShadowcornEggs);
    }

function completeSacrifice() external nonReentrant {
        bytes32 requestId = CurrentSacrificeForSacrificer[_msgSender()];
        require(requestId != 0,"sender has no sacrifice in progress");
    (remainingShadowcornEggsByType,
            totalRemainingShadowcornEggs
        ) = remainingShadowcornEggs();
        Sacrifice storage currentSacrifice = ActiveSacrifices[requested];

        uint256 initialRandomness = currentSacrifice.randomness % 100;
        uint256 secondaryRandomness = currentSacrifice.randomness >> 7;

        if (currenSacrifice.type == 1 || initialRandomness < 50) {
            uint256 eggNumber = secondaryRandomness % totalRemainingShadowcornEggs;
            if (eggNumber < remainingShadowcornEggsByType[0]) {
                terminusPoolId = ShadowcornPoolIds[0];
            } else if /** check for other types**/
      //....
      //..... 
            shadowcornContract.mint(_msgSender(), terminusPoolId, 1, ""); //ERC1155 mint
      }//.....

        delete CurrentSacrificeForSacrificer[_msgSender()];
        emit SacrificeCompleted(_msgSender(), terminusPoolId, requestId);
    }

Simplified:

  • In this example, the randomness depends on the state variable (tokensRemaining), which means when other users call completeMint, random changes for the user; because of this, the user can wait until the random is favorable and, only then, submit the completeMint transaction.
function fulfillRandomness(bytes32 requestId, uint256 randomness)
        internal override 
{
        Sacrifice storage currentRandomness = ActiveRequests[requestId];
        currentRandomness.randomness = randomness;
    currentRandomness.randomnessFulfilled = true;
 }

function completeMint() external nonReentrant {
        bytes32 requestId = CurrentRequestOfUser[_msgSender()];
       uint256 rawRandomNumber = ActiveRequests[requestId].randomness;

    uint256 tokensRemaining = rareTokensLeft + commonTokensLeft;
    require(tokensRemaining > 0, "No tokens left");
    uint256 random = rawRandomNumber % tokensRemaining;
    // chances to receive rare: rareLeft/total, for common: commonLeft/total; 
    if (random < rareTokensLeft) {
       rareTokensLeft--;
       mintRare();
    } else {
     commonTokensLeft--;
     mintCommon();
    }

        delete CurrentSacrificeForSacrificer[_msgSender()];
        emit SacrificeCompleted(_msgSender(), terminusPoolId, requestId);
}

2. Re-Rolling Randomness:

In this example, the user can make the smart contract as NFT receiver, and inside onERC721Received , revert the transaction if the token dna is unfavorable. Due to randomness retry logic, user can keep re-rolling the randomness until the desired dna is obtained.


 
 function fulfillRandomness(bytes32 requestId, uint256 randomness)
         internal
         override
     {
         Hatching storage currentHatching = hatchingByRequest[requestId];
         currentHatching.status = Status.Finished;
     uint256 tokenId = currentHatching.tokenId;
     setDna(tokenId, randomness);
     token.safeTransferFrom(address(this), owner[tokenId], tokenId);        
     }

 function retryHatching(uint256 tokenId) external {
    Hatching storage currentHatching = hatchingByRequest[requestByToken[tokenId]]; 
    require(currentHatching.status == Status.Active && 
        currentHatching.randomnessRequestBlockNumber < block.number + 30, 
        "retry hatching not available");
        LibHatching.retryHatching(tokenId);
    }
}

3. EVILink Chainlink Oracle Manipulation:

  • It shows that a malicious miner still has a slim chance to tamper randomness provided by Chainlink’s VRF solution! Check out this demonstration of how one can manipulate VRF randomness:

Resources for Self-Learning

I’ve compiled some fantastic resources for you on how to properly run and integrate Chainlink VRF:


We hope that this article was informative and useful for you! Thank you for reading! What instruments should we review? What would you be interested in reading about?

By the way, here are some vacant slots in the second quarter of 2023 now so if your project needs an audit — feel free to write to us, visit our public reports page here!


Support is very important to me, with it I can do what I love — educating users!

If you want to support my work, you can send me a donation to the address:

Stay safe!

Subscribe to Officer's Blog
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.