Platform: Code4rena
Start Date: 20/09/2022
Pot Size: $30,000 USDC
Total HM: 12
Participants: 198
Period: 3 days
Judge: 0xean
Total Solo HM: 2
Id: 164
League: ETH
Rank: 7/198
Findings: 6
Award: $865.93
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: Trust
Also found by: 0xSky, CertoraInc, KIntern_NA, bin2chen, hansfriese, neko_nyaa, neumo, rokinot, wastewa
The amounts of token are saved in uint112 variables. The _baseVestedAmount
, which calculates the amount of token that was vested for the user given a timestamp, contains the following line that calculates the relative amount using the time passed from the start of the linear vesting and the total amount of time that the vesting lasts for:
uint112 linearVestAmount = _claim.linearVestAmount * truncatedCurrentVestingDurationSecs / finalVestingDurationSecs;
_claim.linearVestAmount
is a uint112, and truncatedCurrentVestingDurationSecs
and finalVestingDurationSecs
are uint40s, which means that the calculations will be done for uint112s. This can make the numerator to overflow if the result of the multiplication is big enough, although the final result shouldn't overflow at all.
1000
YAM-V2 (which has 24 decimals, so the actual amount is 1e27
) are vested to Alice in a time period of an year (52 weeks, i.e. the duration is 31449600).1e27 * 31449600 / 31449600
. Because the calculation is done on uint112s, the numerator overflows, as 1e27 * 31449600 > 2 ** 112 - 1
. The transaction reverts, Alice can't withdraw her funds, and their are locked in the contract.Manual audit
Consider doing the calculations in uint256 variables, and then (safe) casting the results to the wanted variable sizes.
#0 - 0xean
2022-09-24T19:18:12Z
dupe of #95
🌟 Selected for report: rajatbeladiya
Also found by: 0x4non, CertoraInc, Chom, JLevick, JohnSmith, KIntern_NA, Ruhum, RustyRabbit, ak1, berndartmueller, imare, joestakey, obront, rbserver, rotcivegaf, supernova
32.8268 USDC - $32.83
The admin can accidentally create the wrong claim for a user and then revoke it, or he would like to renew a vesting for a user which his vesting was ended. However, this is not possible, because the current claim's fields are not zeroed when it is revoked or ended.
Basically every user can have only one claim.
Manual audit
Consider deleting the claim's details once it is ended or revoked to allow another claim to be created for the user.
#0 - 0xean
2022-09-24T19:10:08Z
dupe of #140
🌟 Selected for report: __141345__
Also found by: 0xDecorativePineapple, CertoraInc, IllIllI, JohnSmith, MiloTruck, djxploit, hyh, rbserver, zzzitron
Some tokens take a transfer fee (e.g. STA, PAXG). Tokens like these will be supported because the vested amount will be the amount that was actually transferred (i.e. the balance of the contract after the transfer), but it will cause the user to receive less than the amount he was supposed to receive.
Let's assume the VTVLVesting
contract is deployed with the STA token, which takes a 1% transfer fee.
= 100 / 0.99
, so after the transferred amount will be 100 STA) to the contract so it's balance after the transfer is 100 STA.Manual audit
Consider support fee on transfer tokens in a special way that allows transfer of more tokens to match the amount, or write it down so users will know to pre-calculate the amounts from the beginning
#0 - 0xean
2022-09-25T14:09:16Z
dupe of #278
296.3865 USDC - $296.39
Two address tokens exists in the blockchain. For example, Synthetix's ProxyERC20
contract is such a token which exists in many forms (sUSD, sBTC...). Tokens as such can be vested, but the admin can withdraw them even if they are vested by providing the other address to the withdrawOtherToken
function. The only check in this function is that _otherTokenAddress != tokenAddress
, which is irrelevant in the case of two address tokens.
This can make the admin be able to withdraw the vested funds and break the system, because the balance of the contract can be less than the vested amount.
VTVLVesting
is deployed with the sUSD
contract, using its main (proxy) address - 0x57Ab1ec28D129707052df4dF418D58a2D46d5f51
.numTokensReservedForVesting
is 1000e18
.withdrawOtherToken
for 1000e18 sUSD, providing its second address - 0x57Ab1ec28D129707052df4dF418D58a2D46d5f51
. The value of numTokensReservedForVesting
is still 1000e18
, but the balance of the contract is now 0 sUSD.safeTransfer()
because there is insufficient balance of sUSD. Alice can't receive her funds.Manual audit
Replace the address check with a balance check - record the vesting token balance of the contract before and after the transfer and assert that they are equal.
#0 - 0xean
2022-09-24T19:59:20Z
downgrading to M. The fix is a good idea, but this is a pretty rare token implementation and definitely qualifies as an external factor.
#1 - lawrencehui
2022-10-07T08:44:05Z
yes agreed with @0xean that this is very rare and appreciate warden's suggestion on the fix on balance checking.
🌟 Selected for report: AkshaySrivastav
Also found by: 0v3rf10w, 0x040, 0x1f8b, 0x4non, 0x5rings, 0x85102, 0xA5DF, 0xDecorativePineapple, 0xNazgul, 0xSky, 0xSmartContract, 0xbepresent, 0xf15ers, 0xmatt, 2997ms, Aeros, Aymen0909, B2, Bahurum, Bnke0x0, CertoraInc, Chom, ChristianKuri, CodingNameKiki, Deivitto, Diana, Diraco, Dravee, ElKu, Funen, IllIllI, JC, JLevick, JohnSmith, JohnnyTime, KIntern_NA, Lambda, Margaret, MasterCookie, OptimismSec, RaymondFam, Respx, ReyAdmirado, RockingMiles, Rohan16, Rolezn, Ruhum, RustyRabbit, Sm4rty, SooYa, StevenL, TomJ, Tomo, V_B, Waze, Yiko, __141345__, a12jmx, ajtra, ak1, async, ayeslick, aysha, berndartmueller, bin2chen, bobirichman, brgltd, bulej93, c3phas, carrotsmuggler, cccz, ch13fd357r0y3r, chatch, cryptostellar5, cryptphi, csanuragjain, d3e4, datapunk, delfin454000, dic0de, djxploit, durianSausage, eighty, erictee, exd0tpy, fatherOfBlocks, gogo, got_targ, hansfriese, ignacio, ikbkln, indijanc, innertia, joestakey, karanctf, ladboy233, leosathya, lukris02, martin, medikko, millersplanet, nalus, natzuu, neko_nyaa, neumo, obront, oyc_109, pcarranzav, peanuts, pedr02b2, pedroais, peiw, peritoflores, prasantgupta52, rajatbeladiya, rbserver, reassor, ret2basic, rokinot, romand, rotcivegaf, rvierdiiev, sach1r0, seyni, sikorico, slowmoses, sorrynotsorry, supernova, tibthecat, tnevler, ubermensch, yongskiws, zzykxx, zzzitron
18.8655 USDC - $18.87
// Potential TODO: sanity check, if _linearVestAmount == 0, should we perhaps force that start and end ts are the same?
uint112
used for amounts can limit amount of tokens that can be vested. There are token with high number of decimals. The maximum amount of tokens that can be vested if the token has 18 decimals is 5192296858534828. If the token has 24 decimals, the maximum amount is 5192296858.534828. This is even more limited because it needs to be multiplied by the duration of the vest and fit in uint112, which can lead to overflow, so the actual amount is much smaller.🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0x040, 0x1f8b, 0x4non, 0x85102, 0xA5DF, 0xDanielC, 0xNazgul, 0xSmartContract, 0xbepresent, 0xc0ffEE, 0xsam, 2997ms, AkshaySrivastav, Amithuddar, Atarpara, Aymen0909, B2, Bnke0x0, CertoraInc, Chom, ChristianKuri, CodingNameKiki, Deivitto, Diana, DimitarDimitrov, Diraco, Funen, JC, JLevick, JohnSmith, Junnon, KIntern_NA, Lambda, MasterCookie, Matin, Noah3o6, Ocean_Sky, OptimismSec, RaymondFam, Respx, ReyAdmirado, RockingMiles, Rohan16, Rolezn, Ruhum, Saintcode_, Satyam_Sharma, Sm4rty, SnowMan, SooYa, Sta1400, StevenL, Tadashi, Tagir2003, TomJ, Tomio, Tomo, V_B, Waze, WilliamAmbrozic, Yiko, __141345__, a12jmx, adriro, ajtra, ak1, async, aysha, beardofginger, bobirichman, brgltd, bulej93, c3phas, carrotsmuggler, caventa, ch0bu, cryptostellar5, cryptphi, csanuragjain, d3e4, delfin454000, dharma09, djxploit, durianSausage, eighty, emrekocak, erictee, exd0tpy, fatherOfBlocks, francoHacker, gianganhnguyen, gogo, got_targ, hxzy, ignacio, ikbkln, imare, indijanc, jag, jpserrat, karanctf, ladboy233, leosathya, lucacez, lukris02, m9800, malinariy, martin, medikko, mics, millersplanet, mrpathfindr, nalus, natzuu, neko_nyaa, oyc_109, pauliax, peanuts, pedroais, peiw, pfapostol, prasantgupta52, rbserver, ret2basic, rokinot, rotcivegaf, rvierdiiev, sach1r0, samruna, seyni, slowmoses, subtle77, supernova, tgolding55, tibthecat, tnevler, w0Lfrum, yaemsobak, zishansami
12.236 USDC - $12.24
_msgSender()
instead of calling it multiple times. This issue can be seen in multiple functions:
AccessProtected.constructor()
VTVLVesting.withdraw()
VTVLVesting.withdrawAdmin()
However, if the members will be in this order, and the releaseIntervalSecs (which will fit in less than 40 bits) will be saved as a uint32, it will take only 2 storage slots (with even 32 total free bits):struct Claim { // First slot - 160 bits uint40 startTimestamp uint40 endTimestamp; uint40 cliffReleaseTimestamp; uint40 releaseIntervalSecs; // Second slot - 224 bits uint112 linearVestAmount uint112 cliffAmount // Third slot - 120 bits uint112 amountWithdrawn; bool isActive; }
struct Claim { // First slot - 240 bits uint112 linearVestAmount uint40 startTimestamp uint40 endTimestamp; uint40 cliffReleaseTimestamp; bool isActive; // Second slot - 256 bits uint112 cliffAmount uint112 amountWithdrawn; uint32 releaseIntervalSecs; }
Note: additional 16 bits can be saved in the first slot, so if the size of releaseIntervalSecs is not enough we can add more information for it in the first slot, but it is supposed to be enough (maximum interval of
2 ** 32 - 1
).
_baseVestedAmount
checks first if _referenceTs >= _claim.cliffReleaseTimestamp
, and then if _referenceTs > _claim.startTimestamp
. But we know that _claim.cliffReleaseTimestamp <= _claim.startTimestamp
, so we don't need to check the first condition if the second is met. The code should look like this:
// Calculate the linearly vested amount - this is relevant only if we're past the schedule start // at _referenceTs == _claim.startTimestamp, the period proportion will be 0 so we don't need to start the calc if(_referenceTs > _claim.startTimestamp) { vestAmt += _claim.cliffAmount; uint40 currentVestingDurationSecs = _referenceTs - _claim.startTimestamp; // How long since the start // Next, we need to calculated the duration truncated to nearest releaseIntervalSecs uint40 truncatedCurrentVestingDurationSecs = (currentVestingDurationSecs / _claim.releaseIntervalSecs) * _claim.releaseIntervalSecs; uint40 finalVestingDurationSecs = _claim.endTimestamp - _claim.startTimestamp; // length of the interval // Calculate the linear vested amount - fraction_of_interval_completed * linearVestedAmount // Since fraction_of_interval_completed is truncatedCurrentVestingDurationSecs / finalVestingDurationSecs, the formula becomes // truncatedCurrentVestingDurationSecs / finalVestingDurationSecs * linearVestAmount, so we can rewrite as below to avoid // rounding errors uint112 linearVestAmount = _claim.linearVestAmount * truncatedCurrentVestingDurationSecs / finalVestingDurationSecs; // Having calculated the linearVestAmount, simply add it to the vested amount vestAmt += linearVestAmount; } else if (_referenceTs >= _claim.cliffReleaseTimestamp) { vestAmt += _claim.cliffAmount; }
currentVestingDurationSecs - currentVestingDurationSecs % _claim.releaseIntervalSecs
instead of (currentVestingDurationSecs / _claim.releaseIntervalSecs) * _claim.releaseIntervalSecs
_baseVestedAmount
functioncalldata
instead of memory
when passing struct or array arguments to functions. This can be seen in the createClaimsBatch
function.memory
instead of storage
when fetching the Claim struct of a user inside a function to avoid multiple SLOADs to get the different values of the struct. This can be seen at:
_linearVestAmount + _cliffAmount
in line 256 in the _createClaimUnchecked
function. This calculation is done again in the end of this function (line 292), and caching it will save gas.numTokensReservedForVesting + allocatedAmount
in line 295, and store it later in numTokensReservedForVesting
instead of adding allocatedAmount
to it again. This will both save gas for the double calculation and will save an SLOAD.++i
instead of i++
. This will save gas and has the same effect in this case.usrClaim.amountWithdrawn
in the withdraw
and revokeClaim
functions to save SLOADs. This value is accessed multiple times, and caching it will reduce the number of SLOADs.VariableSupplyERC20Token
contract differently to save gas spent on:constructor(string memory name_, string memory symbol_, uint256 initialSupply_, uint256 maxSupply_) ERC20(name_, symbol_) { // max supply == 0 means mint at will. // initialSupply_ == 0 means nothing preminted // Therefore, we have valid scenarios if either of them is 0 // However, if both are 0 we might have a valid scenario as well - user just wants to create a token but doesn't want to mint anything // Should we allow this? bool initialSupplyPositive = initialSupply_ > 0; require(initialSupplyPositive || maxSupply_ > 0, "INVALID_AMOUNT"); if(initialSupplyPositive) { require(initialSupply_ <= maxSupply_, "INVALID_AMOUNT"); unchecked { maxSupply_ -= initialSupply_; } _mint(_msgSender(), initialSupply_); } mintableSupply = maxSupply_; }
!= 0
instead of > 0
when comparing uint
s to 0. This can be seen in: