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: 13/111
Findings: 7
Award: $1,874.45
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: bin2chen
Also found by: AkshaySrivastav, RaymondFam, caventa, cozzetti, csanuragjain, hansfriese, rvierdiiev, shark
777.334 USDC - $777.33
ProtocolDAO implementation does not have a method to take out GGP. So it can't handle ggp unless it updates ProtocolDAO
recordStakingEnd() will pass the rewards of this reward "If the validator is failing at their duties, their GGP will be slashed and used to compensate the loss to our Liquid Stakers"
At this point slashGGP() will be executed and the GGP will be transferred to "ProtocolDAO"
staking.slashGGP():
function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); decreaseGGPStake(stakerAddr, ggpAmt); vault.transferToken("ProtocolDAO", ggp, ggpAmt); }
But the current ProtocolDAO implementation does not have a method to take out GGP. So it can't handle ggp unless it updates ProtocolDAO
1.transfer GGP to ClaimProtocolDAO or 2.Similar to ClaimProtocolDAO, add spend method to retrieve GGP
contract ProtocolDAO is Base { ... + function spend( + address recipientAddress, + uint256 amount + ) external onlyGuardian { + Vault vault = Vault(getContractAddress("Vault")); + TokenGGP ggpToken = TokenGGP(getContractAddress("TokenGGP")); + + if (amount == 0 || amount > vault.balanceOfToken("ProtocolDAO", ggpToken)) { + revert InvalidAmount(); + } + + vault.withdrawToken(recipientAddress, ggpToken, amount); + + emit GGPTokensSentByDAOProtocol(address(this), recipientAddress, amount); + }
#0 - 0xminty
2023-01-04T01:11:21Z
dupe of #571
#1 - GalloDaSballo
2023-01-09T09:49:10Z
Making primary because of suggested mitigation
#2 - c4-judge
2023-01-09T09:49:14Z
GalloDaSballo marked the issue as primary issue
#3 - emersoncloud
2023-01-16T11:08:37Z
#4 - c4-judge
2023-02-02T21:00:31Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - GalloDaSballo
2023-02-02T21:01:48Z
The Warden has shown how, due to a lack of sweep
the default contract for fee handling will be unable to retrieve tokens sent to it.
While the issue will definitely would have been discovered fairly early in Prod, the in-scope system makes it clear that the funds would have been sent to ProtocolDAO.sol and would have been lost indefinitely.
For this reason, I believe the finding to be of High Severity
#6 - emersoncloud
2023-02-07T20:43:04Z
Acknowledged.
Thanks for the report. This is something we're aware of and are not going to fix at the moment.
The funds are transferred to the Vault and the ProtocolDAO contract is upgradeable. Therefore in the future we can upgrade the contract to spend the Vault GGP tokens to return funds to Liquid Stakers.
We expect slashing to be a rare event and might have some manual steps involved in the early days of the protocol to do this process if it occurs
#7 - c4-judge
2023-02-08T08:30:03Z
GalloDaSballo marked the issue as selected for report
🌟 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
4.9672 USDC - $4.97
Judge has assessed an item in Issue #510 as 3 risk. The relevant finding follows:
In red are the state transitions that can only be performed with special privileges
recreateMinipool(): The following transitions will be performed Withdrawable->PreLaunch Error->PreLaunch
createMinipool(): will perform the following transition: Finished->PreLaunch Canceled->PreLaunch
There is a problem with both methods here. The methods only verify that the state can be converted, not that the current state is legal For example:
If the current state is Finished, you can still call recreateMinipool() If the current state is Withdrawable, you can still call createMinipool() This will result in: 1:recreateMinipool() can be front-run by executing recordStakingEnd() to get back the stake first, and then executing recreateMinipool() will get more stake 2:the new stake will overwrite the old stake, the old stake has not been retrieved, resulting in the loss of
#0 - c4-judge
2023-02-09T08:54:05Z
GalloDaSballo marked the issue as duplicate of #213
#1 - c4-judge
2023-02-09T08:54:12Z
GalloDaSballo marked the issue as partial-50
#2 - GalloDaSballo
2023-02-09T08:54:12Z
🌟 Selected for report: HollaDieWaldfee
Also found by: 0xdeadbeef0x, bin2chen, cozzetti, danyams, enckrish, imare, ladboy233
747.4365 USDC - $747.44
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L424-L426 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670-L683
recordStakingEnd() may not be executed when duration > 365 days
recordStakingEnd() is called at the end of the cycle and passes Reward, which is used to reward staking
"If the validator is failing at their duties, their GGP will be slashed and used to compensate the loss to our Liquid Stakers"
is executed by staking.slashGGP(owner, slashGGPAmt);
At that time, slashGGPAmt may be larger than the owner's GGPStake, resulting in an overflow: Because.
The formula for calculating the number of slashGGPAmt is:
ExpectedAVAXRewardsRate * duration / 365 days
ExpectedAVAXRewardsRate is generally set at 10%, the same as MinCollateralizationRatios
So that if duration > 365 days, it will result in slashGGPAmt > GGPStake number staking.slashGGP() will revert:Arithmetic over/underflow causing recordStakingEnd() to fail all the time
test code $ forge test --match testRecordStakingEndWithSlashWithUnderflow
function testRecordStakingEndWithSlashWithUnderflow() public { uint256 duration = 366 days; //****@audit more then 365 day***/ uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; //**** 10% ****// vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); //will fail vm.expectRevert(MinipoolManager.InvalidMultisigAddress.selector); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); //will fail int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID); store.setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Error)); vm.prank(address(rialto)); vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); store.setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Staking)); vm.startPrank(address(rialto)); vm.expectRevert(MinipoolManager.InvalidEndTime.selector); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); skip(duration); vm.expectRevert(MinipoolManager.InvalidAmount.selector); minipoolMgr.recordStakingEnd{value: 0 ether}(mp1.nodeID, block.timestamp, 0 ether); vm.expectRevert(MinipoolManager.InvalidAmount.selector); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 9 ether); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); MinipoolManager.Minipool memory mp1Updated = minipoolMgr.getMinipool(minipoolIndex); assertEq(mp1Updated.status, uint256(MinipoolStatus.Withdrawable)); assertEq(mp1Updated.avaxTotalRewardAmt, 0); assertTrue(mp1Updated.endTime != 0); assertEq(mp1Updated.avaxNodeOpRewardAmt, 0); assertEq(mp1Updated.avaxLiquidStakerRewardAmt, 0); assertEq(minipoolMgr.getTotalAVAXLiquidStakerAmt(), 0); assertEq(staking.getAVAXAssigned(mp1Updated.owner), 0); assertEq(staking.getMinipoolCount(mp1Updated.owner), 0); assertGt(mp1Updated.ggpSlashAmt, 0); assertLt(staking.getGGPStake(mp1Updated.owner), ggpStakeAmt); }
if slashGGPAmt> GGPStake , use GGPStake
function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); + if (getGGPStake(stakerAddr)<ggpAmt){ + ggpAmt = getGGPStake(stakerAddr); + } decreaseGGPStake(stakerAddr, ggpAmt); vault.transferToken("ProtocolDAO", ggp, ggpAmt); }
#0 - c4-judge
2023-01-10T18:26:00Z
GalloDaSballo marked the issue as primary issue
#1 - c4-judge
2023-01-10T18:26:47Z
GalloDaSballo marked the issue as duplicate of #136
#2 - c4-judge
2023-02-03T19:40:46Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - c4-judge
2023-02-08T09:49:25Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: gz627
Also found by: AkshaySrivastav, Allarious, Czar102, HE1M, HollaDieWaldfee, KmanOfficial, adriro, ast3ros, betweenETHlines, bin2chen, brgltd, cccz, chaduke, hihen, imare, mookimgo, neumo, nogo, peanuts, unforgiven
21.713 USDC - $21.71
Judge has assessed an item in Issue #615 as 2 risk. The relevant finding follows:
3:upgradeExistingContract() need unregisterContract() first and then registerContract(). Avoid newAddr==existingAddr. unregisterContract() remove newAddr
function upgradeExistingContract( address newAddr, string memory newName, address existingAddr ) external onlyGuardian {
unregisterContract(existingAddr); registerContract(newAddr, newName);
}unregisterContract(existingAddr);
#0 - c4-judge
2023-02-03T16:43:20Z
GalloDaSballo marked the issue as duplicate of #742
#1 - c4-judge
2023-02-08T08:12:29Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: hansfriese
Also found by: 0Kage, 0xdeadbeef0x, Allarious, Aymen0909, Ch_301, Franfran, HollaDieWaldfee, Jeiwan, Nyx, RaymondFam, SmartSek, adriro, betweenETHlines, bin2chen, cccz, datapunk, immeas, kaliberpoziomka8552, peritoflores, wagmi
21.713 USDC - $21.71
Judge has assessed an item in Issue #510 as 2 risk. The relevant finding follows:
If the current state is Withdrawable, you can still call createMinipool() This will result in: 1:recreateMinipool() can be front-run by executing recordStakingEnd() to get back the stake first, and then executing recreateMinipool() will get more stake 2:the new stake will overwrite the old stake, the old stake has not been retrieved, resulting in the loss of
test code for: Prelaunch->Launched->Staking->Error->Finished(front-run)-> execute recreateMinipool()->Prelaunch
diff --git a/MinipoolManager.t.sol.orig b/MinipoolManager.t.sol index ed2a6e8..a055d9c 100644 --- a/MinipoolManager.t.sol.orig +++ b/MinipoolManager.t.sol @@ -577,6 +577,43 @@ contract MinipoolManagerTest is BaseTest { assertEq(mp1finished.status, uint256(MinipoolStatus.Finished)); }
uint256 duration = 4 weeks;
uint256 depositAmt = 1000 ether;
uint256 avaxAssignmentRequest = 1000 ether;
uint256 validationAmt = depositAmt + avaxAssignmentRequest;
// Enough to start but not to re-stake, we will add more later
uint128 ggpStakeAmt = 100 ether;
vm.startPrank(nodeOp);
ggp.approve(address(staking), MAX_AMT);
staking.stakeGGP(ggpStakeAmt);
MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration);
vm.stopPrank();
address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT);
vm.prank(liqStaker1);
ggAVAX.depositAVAX{value: MAX_AMT}();
vm.prank(address(rialto));
minipoolMgr.claimAndInitiateStaking(mp.nodeID);
bytes32 txID = keccak256("txid");
vm.prank(address(rialto));
minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp);
skip(duration / 2);
// Give rialto the rewards it needs
uint256 rewards = 10 ether;
deal(address(rialto), address(rialto).balance + rewards);
// Pay out the rewards
vm.prank(address(rialto));
minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, rewards);
// Add a bit more collateral to cover the compounding rewards
vm.prank(nodeOp);
staking.stakeGGP(1 ether);
//***audit:frontrun finshed
vm.prank(nodeOp);
minipoolMgr.withdrawMinipoolFunds(mp.nodeID);
MinipoolManager.Minipool memory oldMp = minipoolMgr.getMinipoolByNodeID(mp.nodeID);
assertEq(staking.getAVAXStake(oldMp.owner), 0); //***audit:AVAXStake == 0
vm.prank(address(rialto));
minipoolMgr.recreateMinipool(mp.nodeID);
assertEq(staking.getAVAXStake(mp.owner), oldMp.avaxNodeOpRewardAmt); //***audit:AVAXStake >0
#0 - c4-judge
2023-02-09T08:53:59Z
GalloDaSballo marked the issue as duplicate of #569
#1 - GalloDaSballo
2023-02-09T08:54:15Z
#2 - c4-judge
2023-02-09T08:54:30Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: bin2chen
Also found by: 0xbepresent, 0xhunter, RaymondFam, Saintcode_, adriro, ast3ros, cryptonue, immeas
233.2002 USDC - $233.20
When more than 10 mulitsig, it is impossible to modify or delete the old ones, making it impossible to create new valid ones.
MultisigManager limits the number of Multisig to 10, which cannot be deleted or replaced after they have been disable This will have a problem, if the subsequent use of 10, all 10 for some reason, be disabled Then it is impossible to add new ones and replace the old ones, so you have to continue using the old Multisig at risk
function registerMultisig(address addr) external onlyGuardian { int256 multisigIndex = getIndexOf(addr); if (multisigIndex != -1) { revert MultisigAlreadyRegistered(); } uint256 index = getUint(keccak256("multisig.count")); if (index >= MULTISIG_LIMIT) { revert MultisigLimitReached(); //***@audit limt 10, and no other way to delete or replace the old Multisig ***// }
add replace old mulitsig method
function replaceMultisig(address addr,address oldAddr) external onlyGuardian { int256 multisigIndex = getIndexOf(oldAddr); if (multisigIndex == -1) { revert MultisigNotFound(); } setAddress(keccak256(abi.encodePacked("multisig.item", multisigIndex, ".address")), addr); emit RegisteredMultisig(addr, msg.sender); }
#0 - c4-judge
2023-01-09T18:58:50Z
GalloDaSballo marked the issue as primary issue
#1 - 0xju1ie
2023-01-19T11:45:44Z
Id argue Low since its unlikely
#2 - emersoncloud
2023-01-19T11:54:40Z
I disagree @0xju1ie. I think it's an oversight not to have a way to delete old multisigs with the limit in place rather than a quality assurance issue.
#3 - 0xju1ie
2023-01-19T14:06:47Z
https://github.com/code-423n4/2022-12-gogopool-findings/issues/349 has an interesting fix for this issue
#4 - emersoncloud
2023-01-24T13:29:38Z
Which was: "Count only the validated/enabled multisigs in order to control the limit."
#5 - GalloDaSballo
2023-02-02T11:59:40Z
The Warden has shown how, due to a logic flaw, the system can only ever add up to 10 multi sigs, even after disabling all, no more multi sigs could be added.
Because this shows how an external condition can break the functionality of the MultisigManager, I agree with Medium Severity
#6 - emersoncloud
2023-02-07T20:51:30Z
Acknowledged.
Not fixing right now, we don't foresee having many multisigs at launch, and will upgrade as necessary to support more.
#7 - c4-judge
2023-02-08T08:31:01Z
GalloDaSballo marked the issue as selected for report
🌟 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
recordStakingError() no reduction minipoolcount resulting in no stake, and you can still get rewards
minipoolCount will be increased or decreased during state transfer 1.->Prelaunch: minipoolCount++ 2.->Withdrawable: minipoolcount-- 3.->Canceled: minipoolCount-- Other states do not operate on minipoolCount
However, according to the existing state transfer, the following path exists:
->Prelaunch(+1)->Launched->ERROR->Finished
If the above path is executed, minipoolCount will be increased by 1, because ERROR does not reduce minipoolcount.
minipoolCount represents how many minipools the user currently has, and when it equals 0, it will no longer be able to get rewards. The RewardsStartTime is set to 0 when minipoolCount==0 by the following control
function calculateAndDistributeRewards(address stakerAddr, uint256 totalEligibleGGPStaked) external onlyMultisig { ... uint256 minipoolCount = staking.getMinipoolCount(stakerAddr); if (minipoolCount == 0) { staking.setRewardsStartTime(stakerAddr, 0); //****@audit minipoolCount==0, set RewardsStartTime =0 } } function isEligible(address stakerAddr) external view returns (bool) { ... //***@audit rewardsStartTime==0 ,no Eligible return (rewardsStartTime != 0 && elapsedSecs >= dao.getRewardsEligibilityMinSeconds()); }
This will result in the minipoolCount always being greater than 0, although there is no more stake, you can still get the reward.
test code:
forge test --match testMinipoolCountError
diff --git a/MinipoolManager.t.sol.orig b/MinipoolManager.t.sol index ed2a6e8..a055d9c 100644 --- a/MinipoolManager.t.sol.orig +++ b/MinipoolManager.t.sol @@ -577,6 +577,43 @@ contract MinipoolManagerTest is BaseTest { assertEq(mp1finished.status, uint256(MinipoolStatus.Finished)); } + function testMinipoolCountError() public { + 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(); + + assertEq(staking.getMinipoolCount(nodeOp), 1); //****@audit ->PreLPrelaunch , MinipoolCount++ + + + address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); + vm.prank(liqStaker1); + ggAVAX.depositAVAX{value: MAX_AMT}(); + + vm.prank(address(rialto)); + minipoolMgr.claimAndInitiateStaking(mp1.nodeID); + + bytes32 txID = keccak256("txid"); + vm.prank(address(rialto)); + minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); + + bytes32 errorCode = "INVALID_NODEID"; + + vm.prank(address(rialto)); + minipoolMgr.recordStakingError{value: validationAmt}(mp1.nodeID, errorCode); + + vm.prank(nodeOp); + minipoolMgr.withdrawMinipoolFunds(mp1.nodeID); + assertEq(staking.getMinipoolCount(nodeOp), 1); //****@audit when Finished , still MinipoolCount==1 + } + function testBondZeroGGP() public { vm.startPrank(nodeOp); address nodeID = randAddress();
function recordStakingError(address nodeID, bytes32 errorCode) external payable { ... Staking staking = Staking(getContractAddress("Staking")); staking.decreaseAVAXAssigned(owner, avaxLiquidStakerAmt); + staking.decreaseMinipoolCount(owner);
#0 - emersoncloud
2023-01-18T14:00:54Z
#1 - c4-judge
2023-01-29T19:06:37Z
GalloDaSballo marked the issue as duplicate of #235
#2 - c4-judge
2023-02-08T19:40:20Z
GalloDaSballo marked the issue as satisfactory