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: 14/111
Findings: 11
Award: $1,862.32
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: bin2chen
Also found by: AkshaySrivastav, RaymondFam, caventa, cozzetti, csanuragjain, hansfriese, rvierdiiev, shark
597.9492 USDC - $597.95
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Staking.sol#L379-L383
Slashed ggp tokens should be distributed with liquidity providers, but they are sent to ProtocolDAO. As result, liquidity providers are not compensated and received no rewards for lending avax to slashed minipool.
By documentation of protocol if validator is slashed, then some amount of his locked GGP tokens should be distributed to liquidity providers to compensate their lost rewards.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Staking.sol#L379-L383
function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); decreaseGGPStake(stakerAddr, ggpAmt); vault.transferToken("ProtocolDAO", ggp, ggpAmt); }
As you can see currently slashed amount is sent directly to vault and controlled by ProtocolDAO. And there is no place in the code where this slashed ggp tokens are distributed among liquidity providers, there is no even calculation of total slashed amount that should be used to distribute tokens.
As result, liquidity providers are not compensated and received no rewards for lending avax to minipool.
VsCode
You need to add mechanism to distribute slashed ggp awards among liquidity providers.
#0 - c4-judge
2023-01-10T18:24:44Z
GalloDaSballo marked the issue as duplicate of #544
#1 - c4-judge
2023-01-29T19:39:37Z
GalloDaSballo marked the issue as duplicate of #532
#2 - c4-judge
2023-02-09T07:52:00Z
GalloDaSballo marked the issue as satisfactory
🌟 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-L269 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L385-L440
Because it's possible for owner of minipool to call createMinipool
function after recordStakingEnd
was called and minipool status became Withdrawable, owner can loose earned validator rewards and operator staked amount.
createMinipool
function can be called if current minipool status is Withdrawable, Error, Finished or Canceled. It's checked using requireValidStateTransition
function.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L152-L175
function requireValidStateTransition(int256 minipoolIndex, MinipoolStatus to) private view { bytes32 statusKey = keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")); MinipoolStatus currentStatus = MinipoolStatus(getUint(statusKey)); bool isValid; if (currentStatus == MinipoolStatus.Prelaunch) { isValid = (to == MinipoolStatus.Launched || to == MinipoolStatus.Canceled); } else if (currentStatus == MinipoolStatus.Launched) { isValid = (to == MinipoolStatus.Staking || to == MinipoolStatus.Error); } else if (currentStatus == MinipoolStatus.Staking) { isValid = (to == MinipoolStatus.Withdrawable || to == MinipoolStatus.Error); } else if (currentStatus == MinipoolStatus.Withdrawable || currentStatus == MinipoolStatus.Error) { isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Prelaunch); } else if (currentStatus == MinipoolStatus.Finished || currentStatus == MinipoolStatus.Canceled) { // Once a node is finished/canceled, if they re-validate they go back to beginning state isValid = (to == MinipoolStatus.Prelaunch); } else { isValid = false; } if (!isValid) { revert InvalidStateTransition(); } }
It's possible to call createMinipool
function for both existing and new nodeId.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L242-L263
if (minipoolIndex != -1) { requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex); // Also reset initialStartTime as we are starting a whole new validation setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), 0); } else { minipoolIndex = int256(getUint(keccak256("minipool.count"))); // The minipoolIndex is stored 1 greater than actual value. The 1 is subtracted in getIndexOf() setUint(keccak256(abi.encodePacked("minipool.index", nodeID)), uint256(minipoolIndex + 1)); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".nodeID")), nodeID); addUint(keccak256("minipool.count"), 1); } // Save the attrs individually in the k/v store setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".delegationFee")), delegationFee); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr")), multisig); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpInitialAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), avaxAssignmentRequest);
In case if it was called for existing minipool then resetMinipoolData
will be called, which will reset some minipool stored data.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L687-L696
function resetMinipoolData(int256 index) private { setBytes32(keccak256(abi.encodePacked("minipool.item", index, ".txID")), 0); setUint(keccak256(abi.encodePacked("minipool.item", index, ".startTime")), 0); setUint(keccak256(abi.encodePacked("minipool.item", index, ".endTime")), 0); setUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxTotalRewardAmt")), 0); setUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxNodeOpRewardAmt")), 0); setUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerRewardAmt")), 0); setUint(keccak256(abi.encodePacked("minipool.item", index, ".ggpSlashAmt")), 0); setBytes32(keccak256(abi.encodePacked("minipool.item", index, ".errorCode")), 0); }
As you can see avaxNodeOpRewardAmt
value will be reset to 0.
Later avaxNodeOpAmt param will be set to msg.value.
We will consider case when createMinipool
for existing minipool is called after recordStakingEnd
function set minipool status to Withdrawable.
Also this function set avaxNodeOpRewardAmt
param to the value that should be returned to owner as reward.
By design, after recordStakingEnd
function is called, then owner should call withdrawMinipoolFunds
function which will send him rewards and staked avax amount.
But if owner will call createMinipool
function for any reason then as result his avaxNodeOpRewardAmt
param will be reset and avaxNodeOpAmt
will be set for new value. As result user can't call withdrawMinipool anymore and also his rewards and previously staked avax amount are cleared, he lost them.
This is a test that creates minipool, then after it's withdrawable creates it again and lose rewards and previous avax stake. Add this test to MinipoolManager.t.sol file.
function testRecreatePool() public { address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); skip(duration); uint256 rewards = 10 ether; uint256 halfRewards = rewards / 2; deal(address(rialto), address(rialto).balance + rewards); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp1.nodeID, block.timestamp, rewards); uint256 percentage = dao.getMinipoolNodeCommissionFeePct(); uint256 commissionFee = (percentage).mulWadDown(halfRewards); vm.stopPrank(); int256 index = minipoolMgr.getIndexOf(mp1.nodeID); mp1 = minipoolMgr.getMinipool(index); //owner provided avaxNodeOpAmt and got 5750000000000000000 as reward assertEq(mp1.avaxNodeOpAmt, depositAmt); assertEq(mp1.avaxNodeOpRewardAmt, 5750000000000000000); vm.startPrank(nodeOp); //now owner creates minipool again with same node and deposited same amount minipoolMgr.createMinipool{value: depositAmt}(mp1.nodeID, duration, 0.02 ether, avaxAssignmentRequest); index = minipoolMgr.getIndexOf(mp1.nodeID); MinipoolManager.Minipool memory mp2 = minipoolMgr.getMinipool(index); assertEq(mp1.nodeID, mp2.nodeID); //his avaxNodeOpAmt is depositAmt, but should be 2 * depositAmt assertEq(mp2.avaxNodeOpAmt, depositAmt); //his rewards are 0 now assertEq(mp2.avaxNodeOpRewardAmt, 0); vm.expectRevert(); //he can't withdraw anymore minipoolMgr.withdrawMinipoolFunds(mp1.nodeID); }
VsCode
Do not allow transition to Prelaunch from Withdrawable status.
#0 - 0xminty
2023-01-04T00:10:49Z
dupe of #805
#1 - c4-judge
2023-01-09T12:37:40Z
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:11Z
GalloDaSballo changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-08T20:29:26Z
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:52:30Z
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
Share price manipulation by first depositor of TokenggAVAX is possible. Next depositors will be losing funds.
When TokenggAVAX is deployed first depositor can deposit 1 wei using depositAVAX
function. As result 1 share will be minted for him. The price of share will be 1 wei. Then he will transfer big amount of WAVAX directly to TokenggAVAX contract. As result price of that 1 share will increase to the amount that he donated.
Then all next depositors should deposit big amount of funds to be able to get shares. And also all amount that is victimDepositAmount%sharePrice != 0 will be lost by depositors, because of rounding while calculating shares, while attacker will benefit of it, as his assets count will be increasing.
VsCode
First depositor should not be able to call depositAVAX
function with small msg.value.
#0 - c4-judge
2023-01-08T13:11:58Z
GalloDaSballo marked the issue as duplicate of #209
#1 - c4-judge
2023-02-08T09:44:47Z
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
Rialto can call finishFailedMinipoolByMultisig
when minipool is not in Error state. As result minipool owner will not be able to withdraw minipool funds.
finishFailedMinipoolByMultisig
is going to be used to change state of minipool that faced with error to Finished.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L528-L533
function finishFailedMinipoolByMultisig(address nodeID) external { int256 minipoolIndex = onlyValidMultisig(nodeID); requireValidStateTransition(minipoolIndex, MinipoolStatus.Finished); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Finished)); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Finished); }
requireValidStateTransition(minipoolIndex, MinipoolStatus.Finished)
is checking if such transition is allowed. And indeed it is allowed when minipool is in Error state, but it is allowed for Withdrawable
state as well.
So it's possible that rialto will call finishFailedMinipoolByMultisig
function for minipool that has no error and earned rewards. Because of that owner of minipool will not be able to call withdrawMinipoolFunds
and return staked amount and rewards.
This test shows that indeed it's possible for rialto to call finishFailedMinipoolByMultisig
when minipool is in Withdrawable state. Add it to MinipoolManager.t.sol.
function testPoolMarkedAsFinished() public { address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); skip(duration); uint256 rewards = 10 ether; uint256 halfRewards = rewards / 2; deal(address(rialto), address(rialto).balance + rewards); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp1.nodeID, block.timestamp, rewards); uint256 percentage = dao.getMinipoolNodeCommissionFeePct(); uint256 commissionFee = (percentage).mulWadDown(halfRewards); vm.stopPrank(); vm.prank(address(rialto)); //it's withdrawable now, but rialto marked it as finished minipoolMgr.finishFailedMinipoolByMultisig(mp1.nodeID); vm.prank(address(nodeOp)); vm.expectRevert(); //he can't withdraw anymore minipoolMgr.withdrawMinipoolFunds(mp1.nodeID); }
VsCode
Check that minipool state is Error when calling finishFailedMinipoolByMultisig
.
#0 - c4-judge
2023-01-10T10:00:01Z
GalloDaSballo marked the issue as primary issue
#1 - emersoncloud
2023-01-17T09:30:01Z
#2 - c4-judge
2023-01-29T15:28:54Z
GalloDaSballo marked the issue as duplicate of #723
#3 - c4-judge
2023-02-08T20:13:52Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: __141345__
Also found by: 0xbepresent, Deivitto, HollaDieWaldfee, Nyx, PaludoX0, RaymondFam, ak1, cccz, ck, cryptostellar5, csanuragjain, ladboy233, rvierdiiev
68.0946 USDC - $68.09
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Staking.sol#L328-L332 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ClaimNodeOp.sol#L106
Staking.restakeGGP
should not be allowed when contract is paused. Even that it's only callable by ClaimNodeOp
, it's still can be directly called by user through ClaimNodeOp.claimAndRestake
.
When Staking contract is paused, then staking and withdrawing of GGP is disallowed. So both stakeGGP
and withdrawGGP
functions have whenNotPaused
modifier.
Also there is another possibility to stake GGP using Staking.restakeGGP
function. It's only callable by ClaimNodeOp
. And this function doesn't have whenNotPaused
modifier.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Staking.sol#L328-L332
function restakeGGP(address stakerAddr, uint256 amount) public onlySpecificRegisteredContract("ClaimNodeOp", msg.sender) { // Transfer GGP tokens from the ClaimNodeOp contract to this contract ggp.safeTransferFrom(msg.sender, address(this), amount); _stakeGGP(stakerAddr, amount); }
restakeGGP
can be called only by ClaimNodeOp
. It is called inside ClaimNodeOp.claimAndRestake
function.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ClaimNodeOp.sol#L89-L114
function claimAndRestake(uint256 claimAmt) external { Staking staking = Staking(getContractAddress("Staking")); uint256 ggpRewards = staking.getGGPRewards(msg.sender); if (ggpRewards == 0) { revert NoRewardsToClaim(); } if (claimAmt > ggpRewards) { revert InvalidAmount(); } staking.decreaseGGPRewards(msg.sender, ggpRewards); Vault vault = Vault(getContractAddress("Vault")); uint256 restakeAmt = ggpRewards - claimAmt; if (restakeAmt > 0) { vault.withdrawToken(address(this), ggp, restakeAmt); ggp.approve(address(staking), restakeAmt); staking.restakeGGP(msg.sender, restakeAmt); } if (claimAmt > 0) { vault.withdrawToken(msg.sender, ggp, claimAmt); } emit GGPRewardsClaimed(msg.sender, claimAmt); }
As you can see this function can be called by anyone who has ggp rewards.
So using ClaimNodeOp.claimAndRestake
user can dismiss restriction for staking ggp when Staking contract is paused.
VsCode
Add whenNotPaused
modifier to Staking.restakeGGP
function.
#0 - c4-judge
2023-01-08T13:28:06Z
GalloDaSballo marked the issue as duplicate of #351
#1 - c4-judge
2023-01-29T18:15:30Z
GalloDaSballo marked the issue as duplicate of #673
#2 - c4-judge
2023-02-08T08:56:55Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-02-08T08:57:05Z
GalloDaSballo changed the severity to 2 (Med Risk)
🌟 Selected for report: wagmi
Also found by: HollaDieWaldfee, __141345__, chaduke, hansfriese, peritoflores, rvierdiiev, sces60107, slowmoses, supernova
72.6508 USDC - $72.65
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L98
RewardsPool.inflate sets wrong value to RewardsPool.InflationIntervalStartTime
variable. As result inflation logic will be broken and GGP token will inflate much faster than it was expected.
GGP token has limited total supply that is minted on deploy. But at the beginning not all tokens are available. Token amount should inflate during the time to distribute its total supply.
To increase amount of tokens in circulation RewardsPool.startRewardsCycle
function is called.
This function then calls inflate
function.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L82-L100
function inflate() internal { ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime()); (uint256 currentTotalSupply, uint256 newTotalSupply) = getInflationAmt(); TokenGGP ggp = TokenGGP(getContractAddress("TokenGGP")); if (newTotalSupply > ggp.totalSupply()) { revert MaximumTokensReached(); } uint256 newTokens = newTotalSupply - currentTotalSupply; emit GGPInflated(newTokens); dao.setTotalGGPCirculatingSupply(newTotalSupply); addUint(keccak256("RewardsPool.InflationIntervalStartTime"), inflationIntervalElapsedSeconds); setUint(keccak256("RewardsPool.RewardsCycleTotalAmt"), newTokens); }
This function calculates how many periods has passed since last inflate and then increases token's total supply.
It uses getInflationIntervalStartTime
function to know when inflation was done last time. getInflationIntervalStartTime
function just checks InflationIntervalStartTime
variable.
Later inflate function is updating InflationIntervalStartTime
variable to provide time when last inflation was done.
addUint(keccak256("RewardsPool.InflationIntervalStartTime"), inflationIntervalElapsedSeconds);
The problem is that inflationIntervalElapsedSeconds
is set instead of block.timestamp
.
inflationIntervalElapsedSeconds
is a time in seconds between the last inflation was done. It's not a timestamp.
As a result when inflate
will be called next time, then getInflationAmt
function will calculate new tokens incorrectly and will distribute all total supply more faster.
Example.
1.RewardsPool is deployed at time 1000. Then InflationIntervalStartTime == 1000
.
2.Inflation interval is 10.
3.inflate
is called for first time at time 1010. As result price has increased once. InflationIntervalStartTime == inflationIntervalElapsedSeconds == 10
.
4.inflate
is called next time at time 1020. inflationIntervalElapsedSeconds = 1020 - 10 = 1010
. And price has changed 101 times instead of 1 as getInflationAmt
function decided that 101 period has passed since last inflation.
VsCode
Set block.timestamp to inflationIntervalElapsedSeconds variable.
#0 - GalloDaSballo
2023-01-10T10:28:21Z
Should be dup of #648 but something is off, will double check and keep separate for now
#1 - 0xju1ie
2023-01-13T22:28:27Z
I think the warden is confused. The InflationIntervalStartTime
is added to, not set.
According to the code their example should be the following:
Example.
1.RewardsPool is deployed at time 1000. Then InflationIntervalStartTime == 1000
.
2.Inflation interval is 10.
3.inflate
is called for first time at time 1010. As result price has increased once. inflationIntervalElapsedSeconds == 10
, InflationIntervalStartTime == 1010
.
4.inflate is called next time at time 1020. inflationIntervalElapsedSeconds = 1020 - 1010 = 10.
#2 - GalloDaSballo
2023-01-29T18:53:36Z
I believe that despite the grammar mistake, the warden has found a dup of #648
Because they didn't show the end conclusion, am awarding half
#3 - c4-judge
2023-01-29T18:53:45Z
GalloDaSballo marked the issue as duplicate of #648
#4 - c4-judge
2023-01-29T18:54:01Z
GalloDaSballo marked the issue as partial-50
#5 - c4-judge
2023-01-29T18:57:30Z
GalloDaSballo changed the severity to 2 (Med Risk)
🌟 Selected for report: unforgiven
Also found by: caventa, rvierdiiev
506.3161 USDC - $506.32
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L665 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ClaimNodeOp.sol#L56-L85
MinipoolManager._cancelMinipoolAndReturnFunds doesn't reset rewardsStartTime for staker if it has no more minipool
When new minipool is created for staker then his minipoolCount
is increased and rewardsStartTime
is set to current time if was not set before.
This rewardsStartTime
is then used in ClaimNodeOp.isEligible
function which allows to calculate if staker should receive rewards.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ClaimNodeOp.sol#L46-L52
function isEligible(address stakerAddr) external view returns (bool) { Staking staking = Staking(getContractAddress("Staking")); uint256 rewardsStartTime = staking.getRewardsStartTime(stakerAddr); uint256 elapsedSecs = (block.timestamp - rewardsStartTime); ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); return (rewardsStartTime != 0 && elapsedSecs >= dao.getRewardsEligibilityMinSeconds()); }
Later, based on result of isEligible
, rialto will distribute rewards to eligible stakers.
When staker cancels minipool then _cancelMinipoolAndReturnFunds
function is called which decreases minipool count, but do not reset rewardsStartTime
for staker if it was his only minipool.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L646-L665
function _cancelMinipoolAndReturnFunds(address nodeID, int256 index) private { requireValidStateTransition(index, MinipoolStatus.Canceled); setUint(keccak256(abi.encodePacked("minipool.item", index, ".status")), uint256(MinipoolStatus.Canceled)); address owner = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".owner"))); uint256 avaxNodeOpAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxNodeOpAmt"))); uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); Staking staking = Staking(getContractAddress("Staking")); staking.decreaseAVAXStake(owner, avaxNodeOpAmt); staking.decreaseAVAXAssigned(owner, avaxLiquidStakerAmt); staking.decreaseMinipoolCount(owner); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Canceled); Vault vault = Vault(getContractAddress("Vault")); vault.withdrawAVAX(avaxNodeOpAmt); owner.safeTransferETH(avaxNodeOpAmt); }
As result rewardsStartTime
is not 0 and after 14 days staker that doesn't stake really will receive rewards. After that because it was last minipool, rewardsStartTime
will be reset to 0.
So this is how staker can exploit this. 1.Attacker creates minipool. 2.After some time attacker cancels minipool. 3.Attacker receives rewards for 1 period, without staking.
VsCode
Inside _cancelMinipoolAndReturnFunds
function check that this is last minipool. And if yes, then set rewardsStartTime
to 0 as user should not receive rewards for the period.
#0 - c4-judge
2023-01-10T10:12:56Z
GalloDaSballo marked the issue as primary issue
#1 - GalloDaSballo
2023-01-10T10:13:00Z
Making primary because it's concise
#2 - emersoncloud
2023-01-13T18:40:28Z
Severity should be medium, it's a leak of value rather than a direct stealing of funds.
#3 - 0xju1ie
2023-01-16T21:30:11Z
So this is how staker can exploit this. 1.Attacker creates minipool. 2.After some time attacker cancels minipool. 3.Attacker receives rewards for 1 period, without staking.
Although they may be eligible, the Attacker would not receive any rewards in this scenario. The minipool would have to be successfully staked in order to increase the staker's avaxAssignedHighWater
which is what is used to calculate rewards. So they will be eligible but receive none.
#4 - GalloDaSballo
2023-02-01T19:48:16Z
While the specific instance has been disputed, I believe the finding to be valid, have made #555 as Primary and will award 50% to this one due to shacky POC
#5 - c4-judge
2023-02-01T19:48:26Z
GalloDaSballo marked the issue as duplicate of #555
#6 - c4-judge
2023-02-01T19:48:36Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-02-01T19:50:19Z
GalloDaSballo marked the issue as partial-50
🌟 Selected for report: unforgiven
Also found by: 0x73696d616f, 0xNazgul, Bnke0x0, Breeje, HollaDieWaldfee, IllIllI, Rolezn, __141345__, btk, ck, csanuragjain, fs0c, joestakey, kiki_dev, koxuan, rvierdiiev
40.8808 USDC - $40.88
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/TokenggAVAX.sol#L88-L109 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/TokenggAVAX.sol#L166-L189
Users can sandwich syncRewards
function to deposit, receive rewards and then withdraw.
Liquidity providers of TokenggAVAX are rewarded during the time.
When syncRewards
function is called then portion of rewards is added to circulation. So in case if some funds were distributed that means that if you had 1 share in TokenggAVAX then after syncRewards
was called the amount of assets that you can get for this share has increased.
This allows attacker to earn rewards even without locking his funds.
Attack scenario.
1.syncRewards
is called and new rewards will be distributed.
2.attacker front runs syncRewards
and calls depositAVAX
with big amount of funds X.
3.then he calls withdrawAVAX
and as result he receives X+pertOfRewards.
4.as result attacker didn't do nothing but received rewards.
VsCode
You need to have mechanism that will push depositors to lock their funds for a some minimum time. So they can't receive rewards for nothing.
#0 - c4-judge
2023-01-10T17:47:33Z
GalloDaSballo marked the issue as duplicate of #478
#1 - c4-judge
2023-02-03T10:21:38Z
GalloDaSballo changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-02-08T20:12:17Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xbepresent
Also found by: 0xdeadbeef0x, Breeje, Franfran, IllIllI, Matin, Rolezn, SmartSek, btk, ck, datapunk, fs0c, nadin, rvierdiiev, unforgiven
57.1995 USDC - $57.20
TokenggAVAX.totalAssets calculates assets incorrectly when lastSync != previous rewardsCycleEnd.
TokenggAVAX.syncRewards
is going to distribute new rewards during rewardsCycleLength
period. Anyone can call syncRewards
and the time when it was called will be set as lastSync
variable.
function syncRewards() public { uint32 timestamp = block.timestamp.safeCastTo32(); if (timestamp < rewardsCycleEnd) { revert SyncError(); } uint192 lastRewardsAmt_ = lastRewardsAmt; uint256 totalReleasedAssets_ = totalReleasedAssets; uint256 stakingTotalAssets_ = stakingTotalAssets; uint256 nextRewardsAmt = (asset.balanceOf(address(this)) + stakingTotalAssets_) - totalReleasedAssets_ - lastRewardsAmt_; // Ensure nextRewardsCycleEnd will be evenly divisible by `rewardsCycleLength`. uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength; lastRewardsAmt = nextRewardsAmt.safeCastTo192(); lastSync = timestamp; rewardsCycleEnd = nextRewardsCycleEnd; totalReleasedAssets = totalReleasedAssets_ + lastRewardsAmt_; emit NewRewardsCycle(nextRewardsCycleEnd, nextRewardsAmt); }
It's not guaranteed that syncRewards
function will be called exactly at rewardsCycleEnd
time. That means that it's possible that at the time when syncRewards
function was called lastSync
is set to time that is bigger than previous rewardsCycleEnd
.
For example rewardsCycleEnd
is 100, rewardsCycleLength
is 10. So if syncRewards
is called in time 109, then lastSync
is set to 109, and nextRewardsCycleEnd
is 110.
Function totalAssets
is used in contract to calculate amount of assets that are available at the moment. This function is used in many another functions that work with shares.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/TokenggAVAX.sol#L113-L130
function totalAssets() public view override returns (uint256) { // cache global vars uint256 totalReleasedAssets_ = totalReleasedAssets; uint192 lastRewardsAmt_ = lastRewardsAmt; uint32 rewardsCycleEnd_ = rewardsCycleEnd; uint32 lastSync_ = lastSync; if (block.timestamp >= rewardsCycleEnd_) { // no rewards or rewards are fully unlocked // entire reward amount is available return totalReleasedAssets_ + lastRewardsAmt_; } // rewards are not fully unlocked // return unlocked rewards and stored total uint256 unlockedRewards = (lastRewardsAmt_ * (block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_); return totalReleasedAssets_ + unlockedRewards; }
In case if block.timestamp >= rewardsCycleEnd_
then all rewards that are prepared for this period are added to total assets.
But in case if condition is false then next formula is used (lastRewardsAmt_ * (block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_)
.
The problem is that this formula takes into account lastSync_
param and not previous rewardsCycleEnd_
.
As result function calculates smaller amount of totalAssets that it should.
For example if rewardsCycleEnd
is 110, lastSync
is set to 109, and totalAssets
function is called at time 109 that means that 9/10 of lastRewardsAmt_
should be added to totalAssets, but formula will add nothing in this case.
So here is the result of such calculations.
When user calls withdrawAVAX
at time 109 he receives less amount of tokens as rewards that are calculated during this cycle are not added to total assets.
When syncRewards
is called exactly in rewardsCycleEnd timestamp then user that withdraws funds will receive more assets.
Let's consider 2 scenarios. Input: rewardsCycleEnd = 100, rewardsCycleLength = 10.
1.syncRewards
is called at sync=100. then at time 109 function totalAssets
will calculate unlockedRewards as 9/10 of lastRewardsAmt_
.
2.syncRewards
is called at sync=109. then at time 109 function totalAssets
will calculate unlockedRewards as 0.
In second scenario totalAssets is less then in 1 scenario. But they should be equal.
VsCode
You need to linearly increase rewards from cycle start, not sync call.
#0 - c4-judge
2023-01-10T16:16:25Z
GalloDaSballo marked the issue as duplicate of #317
#1 - c4-judge
2023-02-08T19:30:40Z
GalloDaSballo marked the issue as satisfactory
#2 - c4-judge
2023-02-08T19:30:51Z
GalloDaSballo changed the severity to 2 (Med Risk)
🌟 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-L515 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ClaimNodeOp.sol#L56-L85
MinipoolManager.recordStakingError doesn't decrease staker's minipool count which allows staker to receive ggp rewards forever.
When new minipool is created for staker then his minipoolCount
is increased and rewardsStartTime
is set to current time if was not set before.
This rewardsStartTime
is then used in ClaimNodeOp.isEligible
function which allows to calculate if staker should receive rewards.
While minipoolCount
variable is used inside ClaimNodeOp.calculateAndDistributeRewards
function to set rewardsStartTime
to 0 in case if staker doesn't have more minipools. Once it is set to 0 then isEligible
function will return false and staker stops receiving rewards.
When minipool is finished(recorded end) then staker's minipoolCount is decreased. This means that user will be able to receive rewards for that period and in case if it was last minipool, then rewardsStartTime
will be set to 0, so he will not receive rewards for next period.
Now, let's consider recordStakingError
function which is called by rialto to signal that staking failed and return funds to staker.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L484-L515
function recordStakingError(address nodeID, bytes32 errorCode) external payable { int256 minipoolIndex = onlyValidMultisig(nodeID); requireValidStateTransition(minipoolIndex, MinipoolStatus.Error); address owner = getAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner"))); uint256 avaxNodeOpAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt"))); uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt"))); if (msg.value != (avaxNodeOpAmt + avaxLiquidStakerAmt)) { revert InvalidAmount(); } setBytes32(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".errorCode")), errorCode); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Error)); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxTotalRewardAmt")), 0); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpRewardAmt")), 0); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerRewardAmt")), 0); // Send the nodeOps AVAX to vault so they can claim later Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: avaxNodeOpAmt}(); // Return Liq stakers funds ggAVAX.depositFromStaking{value: avaxLiquidStakerAmt}(avaxLiquidStakerAmt, 0); Staking staking = Staking(getContractAddress("Staking")); staking.decreaseAVAXAssigned(owner, avaxLiquidStakerAmt); subUint(keccak256("MinipoolManager.TotalAVAXLiquidStakerAmt"), avaxLiquidStakerAmt); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Error); }
You will notice that this function doesn't decreased minipool count for staker and as result he will be able to get rewards for this minipool and also as this minipool "is lost", his minipool count will never be 0, so rewardsStartTime
will never be set to 0 and he will receive rewards forever.
Problem scenario.
1.Staker created minipool. Minipool count is 1.
2.Error occured when staking, so recordStakingError
was called and user received funds back. Minipool count is 1.
3.Staker never tried to create new minipool again, but was eligible for rewards always.
VsCode
Inside recordStakingError
function decrease minipool count for owner of minipool.
#0 - 0xju1ie
2023-01-13T22:34:33Z
#1 - emersoncloud
2023-01-15T12:53:34Z
We should also reset avaxAssignedHighWater
in recordStakingError
#2 - emersoncloud
2023-01-18T14:02:03Z
Pointing duplicates of this to https://github.com/code-423n4/2022-12-gogopool-findings/issues/235 now
#3 - c4-judge
2023-01-29T19:06:40Z
GalloDaSballo marked the issue as duplicate of #235
#4 - c4-judge
2023-01-31T15:13:52Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-02-08T19:41:20Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: ladboy233
Also found by: __141345__, pauliax, peakbolt, rvierdiiev, unforgiven
369.1045 USDC - $369.10
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L188 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L229
RewardsPool.startRewardsCycle function will revert when there is no enabled multisig because of division by 0. As result it will be not possible to distribute rewards.
RewardsPool.startRewardsCycle
function is called to distribute rewards among dao, multisig and operators.
function startRewardsCycle() external { if (!canStartRewardsCycle()) { revert UnableToStartRewardsCycle(); } emit NewRewardsCycleStarted(getRewardsCycleTotalAmt()); // Set start of new rewards cycle setUint(keccak256("RewardsPool.RewardsCycleStartTime"), block.timestamp); increaseRewardsCycleCount(); // Mint any new tokens from GGP inflation // This will always 'mint' (release) new tokens if the rewards cycle length requirement is met // since inflation is on a 1 day interval and it needs at least one cycle since last calculation inflate(); uint256 multisigClaimContractAllotment = getClaimingContractDistribution("ClaimMultisig"); uint256 nopClaimContractAllotment = getClaimingContractDistribution("ClaimNodeOp"); uint256 daoClaimContractAllotment = getClaimingContractDistribution("ClaimProtocolDAO"); if (daoClaimContractAllotment + nopClaimContractAllotment + multisigClaimContractAllotment > getRewardsCycleTotalAmt()) { revert IncorrectRewardsDistribution(); } TokenGGP ggp = TokenGGP(getContractAddress("TokenGGP")); Vault vault = Vault(getContractAddress("Vault")); if (daoClaimContractAllotment > 0) { emit ProtocolDAORewardsTransfered(daoClaimContractAllotment); vault.transferToken("ClaimProtocolDAO", ggp, daoClaimContractAllotment); } if (multisigClaimContractAllotment > 0) { emit MultisigRewardsTransfered(multisigClaimContractAllotment); distributeMultisigAllotment(multisigClaimContractAllotment, vault, ggp); } if (nopClaimContractAllotment > 0) { emit ClaimNodeOpRewardsTransfered(nopClaimContractAllotment); ClaimNodeOp nopClaim = ClaimNodeOp(getContractAddress("ClaimNodeOp")); nopClaim.setRewardsCycleTotal(nopClaimContractAllotment); vault.transferToken("ClaimNodeOp", ggp, nopClaimContractAllotment); } }
To send rewards to multisig, distributeMultisigAllotment
function is called.
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/RewardsPool.sol#L203-L233
function distributeMultisigAllotment( uint256 allotment, Vault vault, TokenGGP ggp ) internal { MultisigManager mm = MultisigManager(getContractAddress("MultisigManager")); uint256 enabledCount; uint256 count = mm.getCount(); address[] memory enabledMultisigs = new address[](count); // there should never be more than a few multisigs, so a loop should be fine here for (uint256 i = 0; i < count; i++) { (address addr, bool enabled) = mm.getMultisig(i); if (enabled) { enabledMultisigs[enabledCount] = addr; enabledCount++; } } // Dirty hack to cut unused elements off end of return value (from RP) // solhint-disable-next-line no-inline-assembly assembly { mstore(enabledMultisigs, enabledCount) } uint256 tokensPerMultisig = allotment / enabledCount; for (uint256 i = 0; i < enabledMultisigs.length; i++) { vault.withdrawToken(enabledMultisigs[i], ggp, tokensPerMultisig); } }
This function will collect all enabled multisigs using MultisigManager contract. Then it will distribute rewards equally to multisigs.
To calculate amount that will be sent to each multisig next formula is used.
uint256 tokensPerMultisig = allotment / enabledCount;
The problem here is that in case when no enabled multisig is present enabledCount will be 0 and the function will revert with division by 0.
As result the whole startRewardsCycle
function will revert and dao and node operators will not receive rewards.
VsCode
In case when no enabled multisigs at the moment send tokens to the vault to special contract.
#0 - c4-judge
2023-01-08T16:08:17Z
GalloDaSballo marked the issue as duplicate of #143
#1 - c4-judge
2023-02-08T20:00:02Z
GalloDaSballo marked the issue as satisfactory