Puppy Raffle

AI First Flight #1
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: low
Valid

# Rarity distribution off-by-one: `<= 70` assigns 71 of 100 outcomes to common

Rarity distribution off-by-one: <= 70 assigns 71 of 100 outcomes to common

Severity: Low

Description

  • Rarity is bucketed from a % 100 value that yields 0..99, intending a 70/25/5 common/rare/legendary split.

  • The inclusive <= bounds shift the ranges: common takes outcomes 0..70 (71 values) and legendary only 96..99 (4 values), so the realised split is 71/25/4, not 70/25/5.

uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100; // 0..99
@> if (rarity <= COMMON_RARITY) { // 0..70 -> 71 outcomes
tokenIdToRarity[tokenId] = COMMON_RARITY;
@> } else if (rarity <= COMMON_RARITY + RARE_RARITY) { // 71..95 -> 25 outcomes
tokenIdToRarity[tokenId] = RARE_RARITY;
} else { // 96..99 -> 4 outcomes
tokenIdToRarity[tokenId] = LEGENDARY_RARITY;
}

Risk

Likelihood:

  • Occurs on every mint — the boundary math always assigns one extra outcome to common at legendary's expense.

Impact:

  • Legendary NFTs are rarer than advertised and commons over-represented, a fairness/spec mismatch on a value-bearing trait.

Proof of Concept

The rarity roll rarity = keccak256(...) % 100 produces an integer in [0, 99] (100 equally-likely outcomes). Counting the outcomes that fall into each inclusive branch shows the realised probabilities do not match the intended 70/25/5:

// COMMON_RARITY = 70, RARE_RARITY = 25, LEGENDARY_RARITY = 5
// common: rarity <= 70 -> values 0..70 = 71 outcomes -> 71% (intended 70%)
// rare: 70 < rarity <= 95 -> values 71..95 = 25 outcomes -> 25% (intended 25%)
// legendary: rarity > 95 -> values 96..99 = 4 outcomes -> 4% (intended 5%)
// 71 + 25 + 4 == 100: common absorbs the extra outcome (value 70) that should
// belong to the rare band, and legendary is under-weighted by 1%.

Concretely, a roll of exactly 70 is classified common, even though a correct 70/25/5 split requires only values 0..69 (70 outcomes) to be common.

Recommended Mitigation

Use strict < boundaries so the ranges match the documented percentages.

- if (rarity <= COMMON_RARITY) {
+ if (rarity < COMMON_RARITY) {
tokenIdToRarity[tokenId] = COMMON_RARITY;
- } else if (rarity <= COMMON_RARITY + RARE_RARITY) {
+ } else if (rarity < COMMON_RARITY + RARE_RARITY) {
tokenIdToRarity[tokenId] = RARE_RARITY;
} else {
tokenIdToRarity[tokenId] = LEGENDARY_RARITY;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-03] Participants are mislead by the rarity chances.

## Description The drop chances defined in the state variables section for the COMMON and LEGENDARY are misleading. ## Vulnerability Details The 3 rarity scores are defined as follows: ``` uint256 public constant COMMON_RARITY = 70; uint256 public constant RARE_RARITY = 25; uint256 public constant LEGENDARY_RARITY = 5; ``` This implies that out of a really big number of NFT's, 70% should be of common rarity, 25% should be of rare rarity and the last 5% should be legendary. The `selectWinners` function doesn't implement these numbers. ``` uint256 rarity = uint256(keccak256(abi.encodePacked(msg.sender, block.difficulty))) % 100; if (rarity <= COMMON_RARITY) { tokenIdToRarity[tokenId] = COMMON_RARITY; } else if (rarity <= COMMON_RARITY + RARE_RARITY) { tokenIdToRarity[tokenId] = RARE_RARITY; } else { tokenIdToRarity[tokenId] = LEGENDARY_RARITY; } ``` The `rarity` variable in the code above has a possible range of values within [0;99] (inclusive) This means that `rarity <= COMMON_RARITY` condition will apply for the interval [0:70], the `rarity <= COMMON_RARITY + RARE_RARITY` condition will apply for the [71:95] rarity and the rest of the interval [96:99] will be of `LEGENDARY_RARITY` The [0:70] interval contains 71 numbers `(70 - 0 + 1)` The [71:95] interval contains 25 numbers `(95 - 71 + 1)` The [96:99] interval contains 4 numbers `(99 - 96 + 1)` This means there is a 71% chance someone draws a COMMON NFT, 25% for a RARE NFT and 4% for a LEGENDARY NFT. ## Impact Depending on the info presented, the raffle participants might be lied with respect to the chances they have to draw a legendary NFT. ## Recommendations Drop the `=` sign from both conditions: ```diff -- if (rarity <= COMMON_RARITY) { ++ if (rarity < COMMON_RARITY) { tokenIdToRarity[tokenId] = COMMON_RARITY; -- } else if (rarity <= COMMON_RARITY + RARE_RARITY) { ++ } else if (rarity < COMMON_RARITY + RARE_RARITY) { tokenIdToRarity[tokenId] = RARE_RARITY; } else { tokenIdToRarity[tokenId] = LEGENDARY_RARITY; } ```

Support

FAQs

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

Give us feedback!