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: 24/111
Findings: 4
Award: $1,015.59
🌟 Selected for report: 1
🚀 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/MinipoolManager.sol#L670-L683 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Staking.sol#L376-L383 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/TokenggAVAX.sol#L180-L203
We need to compensate the liquid staker with the slashed GGP. Right now, the slashed GGP is transferred and left in ProtocolDAO.
Multisig wallet may slash the GGP of the minipool.
/// @notice Slashes the GPP of the minipool with the given index /// @dev Extracted this because of "stack too deep" errors. /// @param index Index of the minipool function slash(int256 index) private { address nodeID = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".nodeID"))); address owner = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".owner"))); uint256 duration = getUint(keccak256(abi.encodePacked("minipool.item", index, ".duration"))); uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt); setUint(keccak256(abi.encodePacked("minipool.item", index, ".ggpSlashAmt")), slashGGPAmt); emit GGPSlashed(nodeID, slashGGPAmt); Staking staking = Staking(getContractAddress("Staking")); staking.slashGGP(owner, slashGGPAmt); }
and eventually, the slashed amount will be transferred to ProtocolDAO contract.
function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); decreaseGGPStake(stakerAddr, ggpAmt); vault.transferToken("ProtocolDAO", ggp, ggpAmt); }
This is not correct because according to the documents,
See https://code4rena.com/contests/2022-12-gogopool-contest,
Node Operators join the protocol by creating Minipools where they deposit AVAX, request some amount of Liquid Staker AVAX and put up 10% of the requested amount in GGP. GGP, our protocol token, is how we ensure rewards for Liquid Stakers if the Node Operator does not maintain sufficient uptime for Avalanche rewards.
See https://multisiglabs.notion.site/Architecture-Protocol-Overview-4b79e351133f4d959a65a15478ec0121,
When someone creates a new Minipool on our network, they have to put up 10% of their requested AVAX as a collateral in GGP. For example, if they are borrowing 1000 AVAX from liquid stakers, they will have to put up 1000 AVAX * 0.1 GGP in order to stake on our network. If the validator is failing at their duties, their GGP will be slashed and used to compensate the loss to our Liquid Stakers.
Therefore, the slashed GGP needs to be transferred to the liquid staker holder
Manual
function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); decreaseGGPStake(stakerAddr, ggpAmt); --- vault.transferToken("ProtocolDAO", ggp, ggpAmt); +++ vault.transferToken("TokenggAVAX", ggp, ggpAmt); }
function withdrawAVAX(uint256 assets) public returns (uint256 shares) { shares = previewWithdraw(assets); // No need to check for rounding error, previewWithdraw rounds up. beforeWithdraw(assets, shares); _burn(msg.sender, shares); emit Withdraw(msg.sender, msg.sender, msg.sender, assets, shares); IWAVAX(address(asset)).withdraw(assets); msg.sender.safeTransferETH(assets); // add the logic here to transfer the 10% GGP based on shares to the msg.sender }
function redeemAVAX(uint256 shares) public returns (uint256 assets) { // Check for rounding error since we round down in previewRedeem. if ((assets = previewRedeem(shares)) == 0) { revert ZeroAssets(); } beforeWithdraw(assets, shares); _burn(msg.sender, shares); emit Withdraw(msg.sender, msg.sender, msg.sender, assets, shares); IWAVAX(address(asset)).withdraw(assets); msg.sender.safeTransferETH(assets); // add the logic here to transfer the 10% GGP based on shares to the msg.sender }
#0 - c4-judge
2023-01-10T18:24:20Z
GalloDaSballo marked the issue as primary issue
#1 - GalloDaSballo
2023-01-10T18:24:28Z
Primary because of remediation
#2 - 0xju1ie
2023-01-20T14:03:17Z
We transfer it to the Protocol DAO right now because it will have to be sold off and then the avax deposited back to the TokenggAvax rewards. Slashing is somewhat rare so we planned this to be a manual process at first. I think the proper solution would look something like what was suggested in #532
#3 - c4-judge
2023-01-29T19:39:38Z
GalloDaSballo marked the issue as duplicate of #532
#4 - c4-judge
2023-02-02T21:00:29Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - c4-judge
2023-02-09T07:51:42Z
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
Every minipool has an owner and it is recorded in this line
setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender);
of the createMinipool function. However, there is no checking to disallow non owner to recreate the same mini pool which is wrong.
A user creates a minipool
/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); 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); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }
And cancel the minipool
/// @notice Owner of a minipool can cancel the (prelaunch) minipool /// @param nodeID 20-byte Avalanche node ID the Owner registered with function cancelMinipool(address nodeID) external nonReentrant { Staking staking = Staking(getContractAddress("Staking")); ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); int256 index = requireValidMinipool(nodeID); onlyOwner(index); // make sure they meet the wait period requirement if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { revert CancellationTooEarly(); } _cancelMinipoolAndReturnFunds(nodeID, index); }
Then, another user can recreate the minipool again with the same createMinipool function with the same nodeID. This should not be allowed!!
Added a test unit.
function testMinipoolManager() public { address nodeID1 = randAddress(); address nodeOp2 = getActorWithTokens("nodeOp2", MAX_AMT, MAX_AMT); vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(100 ether); vm.stopPrank(); vm.startPrank(nodeOp2); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(100 ether); vm.stopPrank(); { vm.startPrank(nodeOp); MinipoolManager.Minipool memory mp = createMyMinipool(nodeID1, 1000 ether, 1000 ether, 2 weeks); // Create a minipool skip(5 seconds); minipoolMgr.cancelMinipool(nodeID1); // Cancel the minipool after 5 seconds vm.stopPrank(); } { vm.startPrank(nodeOp2); MinipoolManager.Minipool memory mp = createMyMinipool(nodeID1, 1000 ether, 1000 ether, 2 weeks); // Recreate the same minipool with the SAME NODEID by different user vm.stopPrank(); } }
Manual and added a test unit.
Change the createMiniPool function. Add owner checking if minipool is updated.
/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { +++ if(getAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner"))) != msg.sender) { +++ // revert +++ } 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); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }
#0 - c4-judge
2023-01-10T17:02:05Z
GalloDaSballo marked the issue as duplicate of #213
#1 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-08T08:26:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-08T08:50:12Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-08T20:25:41Z
GalloDaSballo marked the issue as satisfactory
#5 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#6 - Simon-Busch
2023-02-09T12:46:57Z
Changed severity back from QA to H as requested by @GalloDaSballo
🌟 Selected for report: unforgiven
Also found by: caventa, rvierdiiev
253.1581 USDC - $253.16
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ClaimNodeOp.sol#L44-L52 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L191-L283
Rewards calculation may include CANCELLED minpool period which is wrong.
rewardsStartTime is important because it determines if a staker is eligible for the upcoming rewards cycle.
/// @notice Determines if a staker is eligible for the upcoming rewards cycle /// @dev Eligiblity: time in protocol (secs) > RewardsEligibilityMinSeconds. Rialto will call this. 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()); }
If a user create a minipool
function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); 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); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }
And cancel the minipool after 5 days
/// @notice Owner of a minipool can cancel the (prelaunch) minipool /// @param nodeID 20-byte Avalanche node ID the Owner registered with function cancelMinipool(address nodeID) external nonReentrant { Staking staking = Staking(getContractAddress("Staking")); ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); int256 index = requireValidMinipool(nodeID); onlyOwner(index); // make sure they meet the wait period requirement if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { revert CancellationTooEarly(); } _cancelMinipoolAndReturnFunds(nodeID, index); }
And recreate the minipool again by calling the same createMinipool function, the same isEligible function abovementioned will include the CANCELLATION PERIOD which is wrong.
See this line in the isEligible function
uint256 elapsedSecs = (block.timestamp - rewardsStartTime);
This is because the rewardsStartTime uses the recorded value from the first createMinipool() call and not the recorded value from the second createManipool() call
Manual
Change the createMinipool function
/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); --- if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); --- } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); 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); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }
#0 - c4-judge
2023-01-10T21:04:26Z
GalloDaSballo marked the issue as duplicate of #251
#1 - c4-judge
2023-02-09T07:55:24Z
GalloDaSballo marked the issue as not a duplicate
#2 - GalloDaSballo
2023-02-09T07:55:38Z
Looks less precise, awarding 25%
#3 - c4-judge
2023-02-09T07:55:40Z
GalloDaSballo marked the issue as duplicate of #555
#4 - c4-judge
2023-02-09T07:55:53Z
GalloDaSballo marked the issue as partial-25
🌟 Selected for report: caventa
Also found by: 0xdeadbeef0x, Allarious, Franfran, __141345__, betweenETHlines, hansfriese, mert_eren, stealthyz, unforgiven, wagmi
154.5481 USDC - $154.55
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L225-L227 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L279-L281
When canceling a minipool that was canceled before, it may skip MinipoolCancelMoratoriumSeconds checking and allow the user to cancel the minipool immediately.
A user may create a minipool.
/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { onlyOwner(minipoolIndex); 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); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }
and after 5 days, the user cancels the minipool
/// @notice Owner of a minipool can cancel the (prelaunch) minipool /// @param nodeID 20-byte Avalanche node ID the Owner registered with function cancelMinipool(address nodeID) external nonReentrant { Staking staking = Staking(getContractAddress("Staking")); ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); int256 index = requireValidMinipool(nodeID); onlyOwner(index); // make sure they meet the wait period requirement if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { revert CancellationTooEarly(); } _cancelMinipoolAndReturnFunds(nodeID, index); }
Then, the user recreates the minipool again by calling the same createMinipool function. Then, the user cancels the minipool immediately. The user should not be allowed to cancel the minpool immediately and he should wait for 5 more days.
Added a test unit to MinipoolManager.t.sol
function testMinipoolManager() public { address nodeID1 = randAddress(); vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(100 ether); { MinipoolManager.Minipool memory mp = createMyMinipool(nodeID1, 1000 ether, 1000 ether, 2 weeks); skip(5 days); minipoolMgr.cancelMinipool(mp.nodeID); // Must skip 5 days to be executed } { MinipoolManager.Minipool memory mp = createMyMinipool(nodeID1, 1000 ether, 1000 ether, 2 weeks); minipoolMgr.cancelMinipool(mp.nodeID); // Do not need 5 days more to be executed which is wrong } vm.stopPrank(); }
Manual and added a test unit
Change the createMinipool function. Always setRewardsStartTime everytime the minippol is recreated.
/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); --- if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); --- } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); 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); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }
#0 - GalloDaSballo
2023-01-09T12:39:52Z
Code + POC -> Primary
#1 - c4-judge
2023-01-09T12:39:55Z
GalloDaSballo marked the issue as primary issue
#2 - 0xju1ie
2023-01-20T12:16:04Z
This solution would mess up other aspects of the protocol. In cancel minipool we should really just check the minipoolStartTime against the cancelMoratoriumSeconds.
#3 - GalloDaSballo
2023-01-29T19:32:21Z
The warden has shown a logic flaw in the Finite State Machine, as shown in the POC, cancelling a second miniPool can be done before MinipoolCancelMoratoriumSeconds
Because the exploit doesn't demonstrate a reliable way to extra value or funds from the protocol, I agree with Medium Severity
#4 - c4-judge
2023-02-08T08:30:52Z
GalloDaSballo marked the issue as selected for report