Beginner FriendlyFoundryNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Rarity follows a continuous uniform distribution, against specification

Summary

The documentation for the protocol in scope defines the following rarities for the NFTs that can be minted:

There are 3 NFTs that can be won in the snek raffle, each with varying rarity.
1. Brown Snek - 70% Chance to get
2. Jungle Snek - 25% Chance to get
3. Cosmic Snek - 5% Chance to get

However, the distribution of rarity among minted NFTs follows a continuous uniform distribution, meaning that there is an equal probability of minting a Brown Snek, a Jungle Snek or a Cosmic Snek.

Vulnerability Details

The protocol uses the Chainlink VRF to generate random values, which are used to determine the rarity of the minted NFT.
However, when the VRFCoordinator performs its callback to fulfillRandomWords, the random value (represented by random_words[0]) generated is used in the following line of code:

rarity: uint256 = random_words[0] % 3

Assuming the value returned from Chainlink is truly random, there is an equal probability that its modulo will return 0, 1, or 2 as rarity values.

To prove this point, it is possible to write a test that simulates a million raffles, and look at the distribution of rarity among the minted NFTs.
If, as suspected, the rarity follows a continuous uniform distribution, the final share of NFTs should be around 33% for each type of item.

To write such a test, it is necessary to modify the VRFCoordinatorV2Mock.vy, as it currently returns a static, not random, value. Modify the fulfillRandomWords function with the following line, using requestId as a source of randomness:

words: uint256[MAX_ARRAY_SIZE] = [77+requestId]

To create random request IDs, we can use the "random" Python library. Import the "random" library and add the following test to the snek_raffle_test.py test suite:

def test_normalDistribution(raffle_boa, vrf_coordinator_boa, entrance_fee):
#we need the URIs to compare them with the result of each raffle
COMMON_SNEK_URI = "ipfs://QmSQcYNrMGo5ZuGm1PqYtktvg1tWKGR7PJ9hQosKqMz2nD"
RARE_SNEK_URI = "ipfs://QmZit9nbdhJsRTt3JBQN458dfZ1i6LR3iPGxGQwq34Li4a"
LEGEND_SNEK_URI = "ipfs://QmRujARrkux8nsUG8BzXJa8TiDyz5sDJnVKDqrk3LLsKLX"
#trackers to count the amount of NFTs minted for each category
common_results = 0
jungle_results = 0
legendary_results = 0
#simulate a thousand raffles
for i in range(1_000):
boa.env.set_balance(USER, STARTING_BALANCE)
with boa.env.prank(USER):
raffle_boa.enter_raffle(value=entrance_fee)
boa.env.time_travel(seconds=INTERVAL + 1)
raffle_boa.request_raffle_winner()
vrf_coordinator_boa.fulfillRandomWords(random.randint(0,1_000_000), raffle_boa.address)
if(raffle_boa.tokenURI(i) == COMMON_SNEK_URI):
common_results += 1
elif(raffle_boa.tokenURI(i) == RARE_SNEK_URI):
jungle_results += 1
elif(raffle_boa.tokenURI(i) == LEGEND_SNEK_URI):
legendary_results += 1
else:
#should never happen
exit()
print("Common NFTs:", common_results)
print("Jungle NFTs:", jungle_results)
print("Legendary NFTs:", legendary_results)

According to the law of large numbers, using a thousand raffles should give us results close to the expected distribution. Running the tests, we would expect each category to get about 333 NFTs. As a matter of fact, these are the results when running the test:

Common NFTs: 342
Jungle NFTs: 333
Legendary NFTs: 325

Impact

The distribution of NFT rarity does not follow the expected one. All NFTs have the same probability of being minted, causing them to average on the same value with each other. No NFT is more valuable, meaning that taking part in more raffles to win a rare prize is not incentivized.

Tools Used

Manual review, VSCode, Pytest

Recommendations

Implement the rarity mechanism described in the documentation. For example, the following modification to the previously shown line of fulfillRandomWords yields the expected rarity:

value: uint256 = random_words[0] % 100
rarity: uint256 = 0
if value <= 69:
rarity = 0
if value > 69 and value <= 94:
rarity = 1
if value > 94:
rarity = 2

Running the test again returns the expected distribution:

Common NFTs: 708
Jungle NFTs: 242
Legendary NFTs: 50
Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Rarity is 1/3 instead of what the docs say

rybvic Auditor
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Rarity is 1/3 instead of what the docs say

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.