Skip to content

Instantly share code, notes, and snippets.

@ClementWalter
Created May 10, 2022 15:00
Show Gist options
  • Save ClementWalter/35ca1aa45796f01ced22081e57be2e7e to your computer and use it in GitHub Desktop.
Save ClementWalter/35ca1aa45796f01ced22081e57be2e7e to your computer and use it in GitHub Desktop.
How to debug a VRF tx

How to debug a Chainlink VRF v2 transaction

Recently, I released a new on-chain CC0 NFT project called the co-bots. This project though had a specificity: while the minting process in on-going, and at given checkpoint, it randomly rewards on of the early adopters with a giveaway in ETH taken from the contract balance.

The launch of the 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 discount mechanism: holder of a previous version of the project are able to redeem a voucher for each v1 CoBots that they own.

But still people started 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 playing again some sort of AkuAuction bug? Is this a rugged bug?

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

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, 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. proving the actual random number.

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

The next section gives, using the CoBots, a step-by-step guide to analyse the oracle callback transaction and consequently observer 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

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 lets 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 draw 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.

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 oracled 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 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. But is this transaction visible?

Alchemy composer

The internal transactions tabs displays the transactions that are were 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 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 on 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:

1fe543e3  // signature
da6e6014fa83d1f504b2de027084f110ef375c5f919d4f24dabd3ff824f5b7e4 // request Id
0000000000000000000000000000000000000000000000000000000000000040 // pointer to memory for randomWords[]
0000000000000000000000000000000000000000000000000000000000000001 // length of array
54210d9c7178a0af88fe45b66ff56d38d2f634b555daf8dbf55e9a0b16f2b718 // uint256 random value

Given these value, the verifier (any anon that followed me so far) can find back values found in the contract. Opening the console of their preferred browser, we can see that:

> BigInt("0xda6e6014fa83d1f504b2de027084f110ef375c5f919d4f24dabd3ff824f5b7e4").toString()
'98799217301508220758223172538644033103911863065754175684428858404639975258084' // requestId

 > 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment