Platform: Code4rena
Start Date: 15/12/2022
Pot Size: $128,000 USDC
Total HM: 28
Participants: 111
Period: 19 days
Judge: GalloDaSballo
Total Solo HM: 1
Id: 194
League: ETH
Rank: 12/111
Findings: 7
Award: $2,105.97
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xdeadbeef0x
Also found by: 0Kage, 0xc0ffEE, 0xmint, AkshaySrivastav, Allarious, Ch_301, Franfran, HollaDieWaldfee, IllIllI, Jeiwan, Lirios, Manboy, adriro, ak1, bin2chen, caventa, chaduke, clems4ever, cozzetti, datapunk, imare, immeas, kaliberpoziomka8552, ladboy233, pauliax, peritoflores, rvierdiiev, sces60107, sk8erboy, stealthyz, unforgiven, wagmi, wallstreetvilkas, yixxas
9.9345 USDC - $9.93
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L196 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L243
Minipool owners can call withdrawMinipoolFunds()
to withdraw funds, but the state Withdrawable
also enables anyone to call createMinipool()
. If an attacker calls createMinipool()
before withdrawMinipoolFunds()
called by the minipool owner, the owner will lose funds.
The graph shows requireValidStateTransition
states.
Assume Alice is a minipool owner of minipoolIndex 1
, and the minipool is in Withdrawable
state. Alice is able to call withdrawMinipoolFunds()
to claim all AVAX funds. But at this time, an attacker can call createMinipool()
on minipoolIndex 1
to create a new minipool. L243 will check requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch)
which means that it’s valid from Withdrawable
state to Prelaunch
state. Finally the attacker will be the minipool owner of minipoolIndex 1
and Alice will lose her funds.
Manual Review
In createMinipool
, check the owner should be the same if the nodeId exists (getIndexOf(nodeID) != -1
).
#0 - 0xminty
2023-01-03T23:39:58Z
dupe of #805
#1 - c4-judge
2023-01-09T12:36:39Z
GalloDaSballo marked the issue as duplicate of #213
#2 - c4-judge
2023-02-03T12:33:01Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-08T08:26:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-02-08T08:50:12Z
GalloDaSballo changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-08T20:23:47Z
GalloDaSballo marked the issue as satisfactory
#7 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#8 - Simon-Busch
2023-02-09T12:48:43Z
Changed severity back from QA to H as requested by @GalloDaSballo
🌟 Selected for report: 0xdeadbeef0x
Also found by: 0xLad, 0xNazgul, 0xSmartContract, 0xbepresent, Arbor-Finance, Breeje, HE1M, IllIllI, Qeew, Rolezn, SEVEN, SamGMK, SmartSek, TomJ, WatchDogs, ak1, btk, ck, datapunk, dic0de, eierina, fs0c, hansfriese, koxuan, ladboy233, peanuts, rvierdiiev, sces60107, tonisives, unforgiven, yongskiws
14.9051 USDC - $14.91
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/TokenggAVAX.sol#L166 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L42
Attackers can manipulate the price per share in TokenggAVAX
to take an unfair share of future users because the protocol allows users to deposit a insignificant amount of tokens.
A malicious early user can deposit() with 1 wei of asset token and get 1 wei of shares. Then he/she can send 10000e18 - 1 of asset tokens and inflate the price per share from 1 to an extreme value of 1e22
(1 + 10000e18 - 1) / 1 = 1e22
A future user who deposits 19999e18 will only receive 1 wei of shares token.
19999e18 * 1 / 10000e18 = 1
he/she would lose 9999e18 if they redeem() right after the deposit().
Manual Review
Require minimum amount of share in deposit function and mint function.
#0 - c4-judge
2023-01-08T13:12:13Z
GalloDaSballo marked the issue as duplicate of #209
#1 - c4-judge
2023-01-29T18:38:55Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-08T09:45:05Z
GalloDaSballo marked the issue as satisfactory
1687.7205 USDC - $1,687.72
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Ocyticus.sol#L37
Ocyticus.pauseEverything()
is called when the defender wants to pause the whole protocol. However, it doesn’t pause RewardPool
. So GGP will keep inflating while the protocol is paused. This is unfair since no one can create mini pool to share the inflated GGP when the protocol is paused.
Ocyticus.pauseEverything()
doesn’t pause RewardPool
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Ocyticus.sol#L37
function pauseEverything() external onlyDefender { ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); dao.pauseContract("TokenggAVAX"); dao.pauseContract("MinipoolManager"); dao.pauseContract("Staking"); disableAllMultisigs(); }
But GGP will keep inflating while the protocol is paused.
Manual Review
Ocyticus.pauseEverything()
should also pause RewardPool
#0 - c4-judge
2023-01-27T19:35:40Z
GalloDaSballo marked the issue as duplicate of #823
#1 - c4-judge
2023-02-08T20:14:36Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xbepresent
Also found by: 0Kage, Atarpara, Ch_301, Manboy, cozzetti, datapunk, immeas, kaliberpoziomka8552, peritoflores, rvierdiiev, sces60107, unforgiven, wagmi, yixxas
57.1995 USDC - $57.20
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L528
Multisig can call finishFailedMinipoolByMultisig()
to change the state to Finished
. But the function doesn't check if the owner has already withdrawn. The minipool owner will lose funds if multisig forcibly or unexpectedly calls the function.
The graph shows requireValidStateTransition
states.
If an error occurs when the user stakes, multisig will call recordStakingError
to set the state to Error
, and the function sends funds to the vault so the minipool owner can call withdrawMinipoolFunds()
to claim later.
But the finishFailedMinipoolByMultisig()
function is able to set the state to Finished
. If multisig calls finishFailedMinipoolByMultisig()
before the minipool owner calls withdrawMinipoolFunds()
, the minipool owner will lose funds.
Manual Review
finishFailedMinipoolByMultisig
should check whether the owner has withdrawn the funds.
#0 - c4-judge
2023-01-10T08:56:12Z
GalloDaSballo marked the issue as duplicate of #581
#1 - c4-judge
2023-01-26T18:54:26Z
GalloDaSballo marked the issue as duplicate of #723
#2 - c4-judge
2023-02-08T20:13:12Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: wagmi
Also found by: HollaDieWaldfee, __141345__, chaduke, hansfriese, peritoflores, rvierdiiev, sces60107, slowmoses, supernova
145.3017 USDC - $145.30
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L84 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L98 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L82 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L66
In RewardPool.inflate()
, it computes inflation based on total inflation intervals elapsed, then it starts a new inflate interval and set RewardsPool.InflationIntervalStartTime
to block.timestamp
(inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime())
).
It should set a start time based on the number of inflation intervals that have elapsed. Otherwise, it could result in a waste of inflation time and make the inflation interval length almost twice longer.
RewardPool.inflate()
computes inflation based on total inflation intervals elapsed
function getInflationAmt() public view returns (uint256 currentTotalSupply, uint256 newTotalSupply) { ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 inflationRate = dao.getInflationIntervalRate(); uint256 inflationIntervalsElapsed = getInflationIntervalsElapsed(); currentTotalSupply = dao.getTotalGGPCirculatingSupply(); newTotalSupply = currentTotalSupply; // Compute inflation for total inflation intervals elapsed for (uint256 i = 0; i < inflationIntervalsElapsed; i++) { newTotalSupply = newTotalSupply.mulWadDown(inflationRate); } return (currentTotalSupply, newTotalSupply); } function inflate() internal { … (uint256 currentTotalSupply, uint256 newTotalSupply) = getInflationAmt(); … }
RewardPool.inflate()
set RewardsPool.InflationIntervalStartTime
to block.timestamp
.
(inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime()))
function inflate() internal { ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime()); … addUint(keccak256("RewardsPool.InflationIntervalStartTime"), inflationIntervalElapsedSeconds); setUint(keccak256("RewardsPool.RewardsCycleTotalAmt"), newTokens); }
According to ProtocolDao.sol
. The length of an inflation interval is 1 day by default.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ProtocolDAO.sol#L40
// GGP Inflation setUint(keccak256("ProtocolDAO.InflationIntervalSeconds"), 1 days);
Thus, if the elapsed time is 47 hours (1 day + 23 hours) and RewardPool.inflate()
is called, only one interval is counted.
Manual Review
set a proper start time of the new inflation interval.
function inflate() internal { … addUint(keccak256("RewardsPool.InflationIntervalStartTime"), getInflationIntervalsElapsed() * dao.getInflationIntervalSeconds()); setUint(keccak256("RewardsPool.RewardsCycleTotalAmt"), newTokens); }
#0 - c4-judge
2023-01-09T20:42:39Z
GalloDaSballo marked the issue as duplicate of #648
#1 - c4-judge
2023-01-29T18:57:20Z
GalloDaSballo changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-02-08T10:02:16Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: HollaDieWaldfee
Also found by: Allarious, Aymen0909, Saintcode_, SmartSek, adriro, bin2chen, cccz, kaliberpoziomka8552, minhtrng, rvierdiiev, sces60107, sk8erboy, wagmi
68.0946 USDC - $68.09
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L484 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L221 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L459 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L437 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L658
A node operator’s minipoolCount
increases when calling MinipoolManager.createMinipool
and MinipoolManager.recreateMinipool
. And the count decreases only when calling MinipoolManager.cancelMinipool
, MinipoolManager.recordStakingEnd
and MinipoolManager.cancelMinipoolByMultisig
.
But if a staking error occurred while registering the node as a validator. MinipoolManager.recordStakingError
will be called. And the state transits to MinipoolStatus.Error
. It doesn’t decrease the minipool count of the node operator. And MinipoolManager.cancelMinipool
, MinipoolManager.recordStakingEnd
and MinipoolManager.cancelMinipoolByMultisig
cannot be called when the state is MinipoolStatus.Error
.
Currently, if minipoolCount
goes wrong, only ClaimNodeOp.calculateAndDistributeRewards
will be affected. A node operator’s rewards time will never be reset. But it could lead to a potential disaster if minipoolCount
is used in the future.
A node operator’s minipoolCount
increases when calling MinipoolManager.createMinipool
and MinipoolManager.recreateMinipool
. Staking.increaseMinipoolCount
is called
function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { … Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); … } function recreateMinipool(address nodeID) external whenNotPaused { … Staking staking = Staking(getContractAddress("Staking")); // Only increase AVAX stake by rewards amount we are compounding // since AVAX stake is only decreased by withdrawMinipool() staking.increaseAVAXStake(mp.owner, mp.avaxNodeOpRewardAmt); staking.increaseAVAXAssigned(mp.owner, compoundedAvaxNodeOpAmt); staking.increaseMinipoolCount(mp.owner); … }
And the count decreases only when calling MinipoolManager.cancelMinipool
, MinipoolManager.recordStakingEnd
and MinipoolManager.cancelMinipoolByMultisig
. Staking.decreaseMinipoolCount
is called.
function cancelMinipool(address nodeID) external nonReentrant { … _cancelMinipoolAndReturnFunds(nodeID, index); } function cancelMinipoolByMultisig(address nodeID, bytes32 errorCode) external { … _cancelMinipoolAndReturnFunds(nodeID, minipoolIndex); } function _cancelMinipoolAndReturnFunds(address nodeID, int256 index) private { … Staking staking = Staking(getContractAddress("Staking")); staking.decreaseAVAXStake(owner, avaxNodeOpAmt); staking.decreaseAVAXAssigned(owner, avaxLiquidStakerAmt); staking.decreaseMinipoolCount(owner); … } function recordStakingEnd( address nodeID, uint256 endTime, uint256 avaxTotalRewardAmt ) external payable { … Staking staking = Staking(getContractAddress("Staking")); staking.decreaseAVAXAssigned(owner, avaxLiquidStakerAmt); staking.decreaseMinipoolCount(owner); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Withdrawable); }
But If MinipoolManager.recordStakingError
is called. Staking.decreaseMinipoolCount
won’t be called.
Manual Review
minipooCount
should correctly decrease when the state transits to MinipoolStatus.Error
Call staking.decreaseMinipoolCount(owner);
in MinipoolManager.recordStakingError
or MinipoolManager.finishFailedMinipoolByMultisig
.
#0 - 0xminty
2023-01-04T00:41:36Z
dupe of #235
#1 - c4-judge
2023-01-09T09:42:15Z
GalloDaSballo marked the issue as duplicate of #235
#2 - c4-judge
2023-02-08T19:38:33Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-02-08T19:43:07Z
GalloDaSballo changed the severity to 2 (Med Risk)
🌟 Selected for report: IllIllI
Also found by: 0xSmartContract, AkshaySrivastav, HollaDieWaldfee, RaymondFam, Rolezn, ast3ros, betweenETHlines, brgltd, chaduke, codeislight, cozzetti, lukris02, pauliax, sces60107
122.8177 USDC - $122.82
We list 5 low-critical findings and 1 non-critical finding:
TokenggAVAX
TokenggAVAX
Solidity docs defined that block.timestamp
is uint256
, but in TokenggAVAX
, it uses uint32
to store timestamps. The uint32.max is 4294967295 which is Sun Feb 07 2106
, it means that the protocol will break when the maturity is greater than 2106.
Manual Review
Define as uint256 or larger bits than 32.
ProtocolDao.setClaimingContractPct
is used to set the percentage a contract is owed for a rewards cycle. But it doesn’t check that the total sum of percentage is 100%.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ProtocolDAO.sol#L107
/// @notice Set the percentage a contract is owed for a rewards cycle function setClaimingContractPct(string memory claimingContract, uint256 decimal) public onlyGuardian valueNotGreaterThanOne(decimal) { setUint(keccak256(abi.encodePacked("ProtocolDAO.ClaimingContractPct.", claimingContract)), decimal); }
Manual Review
setClaimingContractPct
should set the percentages of all the contracts owed for a rewards cycle. Then it can check whether the sum of percentages is 100%.
Test code should be removed in the production environment.
/// @notice Set the rewards rate for validating Avalanche's p-chain /// @dev Used for testing function setExpectedAVAXRewardsRate(uint256 rate) public onlyMultisig valueNotGreaterThanOne(rate) { setUint(keccak256("ProtocolDAO.ExpectedAVAXRewardsRate"), rate); }
Manual Review
Remove these code.
These open TODO should be implemented.
BaseAbstract.sol 6:// TODO remove this when dev is complete Staking.sol 203: // TODO cant use onlySpecificRegisteredContract("ClaimNodeOp", msg.sender) since we also call from increaseMinipoolCount. Wat do? MinipoolManager.sol 412: // TODO Revisit this logic if we ever allow unequal matched funds
Manual Review
Implement these open TODO.
The inherited upgradable contract should have storage gaps.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L11 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/upgradeable/ERC20Upgradeable.sol#L10 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/BaseAbstract.sol#L11
Manual Review
Using storage gap:
uint256[50] __gap;
https://docs.openzeppelin.com/contracts/3.x/upgradeable#storage_gaps
In RewardPool.startRewardsCycle()
, it would start a new reward cycle and set RewardsPool.RewardsCycleStartTime
to block.timestamp
. It could set a start time based on the number of reward cycles that have elapsed.
RewardPool.startRewardsCycle()
set RewardsPool.RewardsCycleStartTime
to block.timestamp
.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L164
function startRewardsCycle() external { … // Set start of new rewards cycle setUint(keccak256("RewardsPool.RewardsCycleStartTime"), block.timestamp); … }
According to ProtocolDao.sol
. The length of a reward cycle is 28 days by default.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ProtocolDAO.sol#L33
// RewardsPool setUint(keccak256("ProtocolDAO.RewardsCycleSeconds"), 28 days); // The time in which a claim period will span in seconds - 28 days by default
Thus, if the elapsed time is 55 days and you call RewardPool.startRewardsCycle()
, only one reward cycle is counted.
Manual Review
set a proper start time of the new reward cycle.
function startRewardsCycle() external { … // Set start of new rewards cycle ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); setUint(keccak256("RewardsPool.RewardsCycleStartTime"), getRewardsCycleStartTime() + getRewardsCyclesElapsed() * dao.getRewardsCycleSeconds()); … }
#0 - GalloDaSballo
2023-01-24T19:46:39Z
block.timestamp is uint32 in TokenggAVAX
L
ProtocolDao.setClaimingContractPct should has reasonable check L
ProtocolDAO.sol code for testing L
open TODO NC
ERC4626Upgradeable.sol, ERC20Upgradeable.sol and BaseAbstract.sol should have storage gap R
RewardPool.startRewardsCycle doesn’t set a proper start time of the new reward cycle. L
4L 1R 1NC
#1 - GalloDaSballo
2023-02-03T15:55:18Z
1L from dups
5L 1R 1NC
#2 - c4-judge
2023-02-03T22:09:45Z
GalloDaSballo marked the issue as grade-b