Clemlaflemme

Posted on May 10, 2022Read on Mirror.xyz

How to debug a Chainlink VRF tx

How to debug a Chainlink VRF v2 transaction, a step-by-step tutorial

Recently, I released a new on-chain CC0 NFT project called the co-bots. This project has also a specificity: during the minting process, at some given checkpoints, it randomly rewards on of the early minters with a giveaway in ETH taken from the contract balance.

The launch of this project was last Friday. Right after the launch, and despite a thorough testing (both local unit-test, rinkeby and external audit of the contract) we faced a bug in the front-end regarding a discount mechanism. In other words tensions was palpable in the dev team!

But still people were able to mint. Approximately one hour after the launch, while we were still trying to figure out the bug with matog.eth, the first checkpoint was reached. Good vibes expected! But, omg, the first random actually was, wait for it, 0. And, wait for it, we took for ourselves, right before the launch, one bot each, precisely the three first token (#0, #1 and #2).

In short, in a random draw mechanism whose advocated purpose was to reward the community, we actually ended up taking for ourselves with a very strange result the first 1 ETH giveaway, picking randomly the token #0.

Does this sound right to you? Are we playing again some sort of AkuAuction bug? Is this a rugged bug?

Eventually the project has been almost stalled since then, and I do understand the emotional impact of this situation. Furthermore, the world "bug" was tweeted by smlg.eth in order to explain this before I had the time to deep dive into the Chainlink VRF v2 mechanism to provide a comprehensive proof of this as a real and honest while unexpected result.

The purpose of this article is hence to showcase how to read Chainlink VRF v2 transactions. This took like a dozen of minutes to figure out, probably 12 minutes too much!

Chainlink VRF v2 mechanism

The Chainlink VRF v2 mechanism is an oracle used to retrieve a verifiable random number in a smart contract. It is before the merge the only way to get true random numbers on-chain.

Since this is an oracle, requesting a random number requires indeed two transactions:

  • the first one from the smart contract to the oracle, requesting a random number
  • the second one from the oracle to the smart contract, providing the random number

When using the Chainlink VRF v2 oracle, the callee inherits from VRFConsumerBaseV2 and implements the dedicated fulfillRandomWords method. This method is called by the oracle when fulfilling the request, ie. providing the actual random number.

Hence, while the first transaction is an outbound transaction easily visible in etherscan, the second one is somehow hidden and needs to be parsed accordingly.

The next section gives, using the CoBots example, a step-by-step guide to analyse the oracle callback transaction and consequently observe the confusing #0 draw.

A step-by-step guide

Chainlink subscription

A caller contract using the Chainlink VRF v2 oracle is called the consumer. It needs to subscribe to the oracle to be able to make any request.

Digging into the "read" view of the CoBots contract on etherscan, one can find the following variable:

chainlinkSubscriptionId: 117

Note the contract may not make this value public though. But I guess that Chainlink’s VRF purpose is transparency so why keeping it private?

This subscription id can be used to head up to the subscription dashboard, managed by Chainlink itself:

https://vrf.chain.link/mainnet/117

The dashboard let monitor the subscription: add funds, cancel, see history of requests.

The caller's request

At the time of writing this article, only one checkpoint was drawn so the dashboard history tab displays only one entry. Clicking on the consumer address, one is redirected to the consumer's Etherscan page, ie. the CoBots V2 contract.

The Chainlink VRF dashboard for the Co-Bots's subscriptionId

Here we know already that this chainlinkSubscriptionId is not fake! And the contract actually called the oracle.

Looking at the transactions tab of the contract on etherscan, one can find the transaction that actually triggered the oracle, called draw. More precisely, in the log page of the transaction, one can find the event emitted by the Chainlink coordinator.

All-in-all, there is no doubt here that the contract actually called the oracle. And that the oracle actually received the request. And that the corresponding entry in the dashboard is the one we thought it was (ouf!).

The oracle's response

Back on the Chainlink dashboard, there is a "Transaction hash" reported for the request. This is the hash of the second transaction.

This transaction is actually an internal Chainlink transaction in the sense that it does not call the callee directly. Indeed, back on etherscan, one sees that it comes from an unknown account to the Chainlink coordinator.

So the coordinator is called back and one can decode the input data of the transaction, ie. figure out which function the oracle called, and the parameters it received.

The input data as it reads on etherscan is:

0xaf198b97205fd9881382b34e2377efe1bb17d17da31765a7532f900d6019f52fc41bafe171d628425b111f7dd72c6d90be447d5e729be8ce2c39efd74a57d5f0350455cb2f9b90748ec701b948631119f97ff4ade0f9a70c4a0cee82fb990e2776873ee657c65b9f83dfc92ce4367365383038f3afbf7fe7717b0ab0f8494632f6f083fbb6ba48df98015acad73cb4b67807cbe37988f552e5867f0d0fff12cfbf269a9a18370c5a32664507b95bd678769d1482308c732505945a93ec82ebbf9c116da6eb6221c6742024885b4854e7e13dc4b04de4e5716d0725dcb7bbb5b0796970ea0000000000000000000000007698c09df1fa465ac8236e60114edfb047f1174bd459452d29ce2c53e222ae677abd3eea766c0e0155be660e75d70a8230524ea4f644853ad6aa1ed456d97428575b3c96ace8ccdbf78b11906f4c51cb8b54e25772a1fbbcee9ef330bf11f2c857f8c385eb7984a04dfcb2c74b8b4af774167a58f2efdf3d93b277e9db439e3e7111e05a38c6b596f0e1c3c5c7851a47e16b9bb6eaaabc0514ca558efd35da12af8366832155e485fe3297261c3e89ce1af288310000000000000000000000000000000000000000000000000000000000e0ae1d0000000000000000000000000000000000000000000000000000000000000075000000000000000000000000000000000000000000000000000000000007a120000000000000000000000000000000000000000000000000000000000000000100000000000000000000000078fc2f8cbe43b02beff806b4f0aeb1eeb0a11894

This input data can “easily” be decoded: the four first bytes (af198b97) are the function selector, and the rest are the parameters. Hence the function is indeed

function fulfillRandomWords(Proof memory proof, RequestCommitment memory rc)

In the body of the function, one can find the following code:

bytes memory resp = abi.encodeWithSelector(v.rawFulfillRandomWords.selector, requestId, randomWords);

which is exactly where our contract gets called.

Note: instead of decoding the input data, a naive search for rawFulfillRandomWords could have done the job since it’s called only once.

But where is this transaction visible?

Alchemy composer

The internal transactions tab displays the transactions that are triggered by another contract. Eventually here we find the transaction that we previously mentioned.

We see that this transaction actually led to a transfer of 1 ETH, but we cannot see the actual value of the random number received by the contract.

To get more information, we can use the Alchemy Composer and its trace_transaction method.

Inputting the given transaction hash, we get as an output a list of all the calls made by the transaction. If we Ctlf+f our CoBots V2 contract address (0x78fc2f8cbe43b02beff806b4f0aeb1eeb0a11894, beware of the case) we find one specific entry:

{
  "action": {
    "from": "0x271682deb8c4e0901d1a1550ad2e64d568e69909",
    "callType": "call",
    "gas": "0x7a120",
    "input": "0x1fe543e3da6e6014fa83d1f504b2de027084f110ef375c5f919d4f24dabd3ff824f5b7e40000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000154210d9c7178a0af88fe45b66ff56d38d2f634b555daf8dbf55e9a0b16f2b718",
    "to": "0x78fc2f8cbe43b02beff806b4f0aeb1eeb0a11894",
    "value": "0x0"
  },
  "blockHash": "0x9779d03ecd404575cb926f1141e8226b54920e992591712a9933549514aeac7e",
  "blockNumber": 14724644,
  "result": {
    "gasUsed": "0x106b5",
    "output": "0x"
  },
  "subtraces": 1,
  "traceAddress": [
    0
  ],
  "transactionHash": "0xdd1a5fb9940a278bd8cc077b90187af2eb0c67ab1b811ed89cb04b6ed1d56902",
  "transactionPosition": 42,
  "type": "call"
}

Bingo! The "input" field is the input data of out own rawFulfillRandomWords function.

Once again, we can decode it:

// function selector
1fe543e3
// requestId
da6e6014fa83d1f504b2de027084f110ef375c5f919d4f24dabd3ff824f5b7e4 
// pointer to memory for arg randomWords[]
0000000000000000000000000000000000000000000000000000000000000040 // length of randomWords
0000000000000000000000000000000000000000000000000000000000000001 // the random value randomWords[0]
54210d9c7178a0af88fe45b66ff56d38d2f634b555daf8dbf55e9a0b16f2b718

Given these values as bytes, the verifier (any anon that followed me so far) can find back values found in the contract. Opening the console of their preferred browser (the one use right at this moment), they can see that:

> BigInt("0xda6e6014fa83d1f504b2de027084f110ef375c5f919d4f24dabd3ff824f5b7e4").toString()
'98799217301508220758223172538644033103911863065754175684428858404639975258084' // requestId as visible on the contract page

 > BigInt("0x54210d9c7178a0af88fe45b66ff56d38d2f634b555daf8dbf55e9a0b16f2b718").toString()
'38052679174536163966270071048856216962117352358951829765782333889304992069400' // randomValue

Because the checkpoint for this draw was 100, we picked the randomValue % 100 as the winning token, and eventually got #0000, OMG!

Conclusion

Though it is still a bit challenging to use Chainlink VRF (see also my post about unit-testing it), it is really worth both for reassuring the community and the devs when something goes against what seems "normal".

In the meantime, the Co-Bots contract has still ~4 ETH locked waiting for the next draw!

Don’t hesitate to have a look at the co-bots monorepo and feel free to reach out to me on Twitter or Discord should you have any question or suggestion!

Chainlink