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: 3/111
Findings: 7
Award: $4,197.78
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: 0xdeadbeef0x
Also found by: 0Kage, 0xc0ffEE, 0xmint, AkshaySrivastav, Allarious, Ch_301, Franfran, HollaDieWaldfee, IllIllI, Jeiwan, Lirios, Manboy, adriro, ak1, bin2chen, caventa, chaduke, clems4ever, cozzetti, datapunk, imare, immeas, kaliberpoziomka8552, ladboy233, pauliax, peritoflores, rvierdiiev, sces60107, sk8erboy, stealthyz, unforgiven, wagmi, wallstreetvilkas, yixxas
9.9345 USDC - $9.93
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L158 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L243
Incorrect validation of node status when handling existing id when creating MiniPool
Let us look into the function createMiniPool:
/// @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 {
which calls:
// 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); }
node the comment:
If nodeID exists, only allow overwriting if node is finished or canceled
However, in the code
requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch);
which calls:
function requireValidStateTransition(int256 minipoolIndex, MinipoolStatus to) private view { bytes32 statusKey = keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")); MinipoolStatus currentStatus = MinipoolStatus(getUint(statusKey)); bool isValid; if (currentStatus == MinipoolStatus.Prelaunch) { isValid = (to == MinipoolStatus.Launched || to == MinipoolStatus.Canceled); }
note the check:
isValid = (to == MinipoolStatus.Launched || to == MinipoolStatus.Canceled);
the intention is to check if the node status is finished or canceled, but the implementation check if the node status is Launched or canceled.
A launched node status should not be overwritten.
Manual Review
Change from
if (currentStatus == MinipoolStatus.Prelaunch) { isValid = (to == MinipoolStatus.Launched || to == MinipoolStatus.Canceled);
to
if (currentStatus == MinipoolStatus.Prelaunch) { isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Canceled);
#0 - c4-judge
2023-01-10T17:12:10Z
GalloDaSballo marked the issue as duplicate of #213
#1 - GalloDaSballo
2023-01-10T17:12:21Z
Valid finding, but in contrast to main, this doesn't show other risks, awarding 50% for FSM
#2 - c4-judge
2023-01-10T17:12:29Z
GalloDaSballo marked the issue as partial-50
#3 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-08T08:26:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-02-08T08:50:12Z
GalloDaSballo changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-08T20:29:36Z
GalloDaSballo marked the issue as satisfactory
#7 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#8 - Simon-Busch
2023-02-09T12:52:18Z
Changed severity back from QA to H as requested by @GalloDaSballo
🌟 Selected for report: 0xdeadbeef0x
Also found by: 0xLad, 0xNazgul, 0xSmartContract, 0xbepresent, Arbor-Finance, Breeje, HE1M, IllIllI, Qeew, Rolezn, SEVEN, SamGMK, SmartSek, TomJ, WatchDogs, ak1, btk, ck, datapunk, dic0de, eierina, fs0c, hansfriese, koxuan, ladboy233, peanuts, rvierdiiev, sces60107, tonisives, unforgiven, yongskiws
14.9051 USDC - $14.91
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L169 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/lib/solmate/src/mixins/ERC4626.sol#L137 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/lib/solmate/src/mixins/ERC4626.sol#L127
A malicious early user/attacker can manipulate the pricePerShare in ggAVAX token vault to take an unfair share of future users' deposits
The attacker can profit from future users' deposits. While the late users will lose part of their funds to the attacker.
A well known attack vector for almost all shares based liquidity pool contracts, where an early user can manipulate the price per share and profit from late users' deposits because of the precision loss caused by the rather large value of price per share.
A malicious early user can deposit() with 1 wei of asset token as the first depositor of the token, and get 1 wei of shares.
Then the attacker can send 10000e18 - 1 of asset tokens and inflate the price per share from 1.0000 to an extreme value of 1.0000e22 ( from (1 + 10000e18 - 1) / 1) .
As a result, the future user who deposits 19999e18 will only receive 1 wei (from 19999e18 * 1 / 10000e18) of shares token.
They will immediately lose 9999e18 or half of their deposits if they redeem() right after the deposit().
Manual Review
Consider requiring a minimal amount of share tokens to be minted for the first minter, and send a port of the initial mints as a reserve to the DAO so that the pricePerShare can be more resistant to manipulation.
#0 - c4-judge
2023-01-08T13:11:43Z
GalloDaSballo marked the issue as duplicate of #209
#1 - c4-judge
2023-02-08T09:44:27Z
GalloDaSballo marked the issue as satisfactory
🌟 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#L34 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L198 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L257 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L675 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L560 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L379 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L94
Node creator can game the duration parameter to trigger underflow when slashing is called.
When miniPool is created, this function called:
/// @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 {
note the parameter duration: duration
There is no boundry check about this parameter, the user can pass in the duration whatever value they want.
In the begining, this parameter is recorded:
setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration);
This parameter is used when need to calculate the slash calculation:
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); }
note the code:
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);
note the function
/// @notice Given a duration and an AVAX amt, calculate how much AVAX should be earned via validation rewards /// @param duration The length of validation in seconds /// @param avaxAmt The amount of AVAX the node staked for their validation period /// @return The approximate rewards the node should recieve from Avalanche for beign a validator function getExpectedAVAXRewardsAmt(uint256 duration, uint256 avaxAmt) public view returns (uint256) { ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 rate = dao.getExpectedAVAXRewardsRate(); return (avaxAmt.mulWadDown(rate) * duration) / 365 days; }
the longer the duration is, the larger the slashed amount is, once the slashed amount is larger than the staked GGP token amount, arithmic underflow happens.
for example, user stake 100 GGP token, but because duration is long, slashed amount goes to 120 GGP, slash function will revert.
then We call
staking.slashGGP(owner, slashGGPAmt);
which calls:
/// @notice Minipool Manager will call this if a minipool ended and was not in good standing /// @param stakerAddr The C-chain address of a GGP staker in the protocol /// @param ggpAmt The amount of GGP being slashed function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); decreaseGGPStake(stakerAddr, ggpAmt); vault.transferToken("ProtocolDAO", ggp, ggpAmt); }
which calls:
/// @notice Decrease the amount of GGP a given staker is staking /// @param stakerAddr The C-chain address of a GGP staker in the protocol function decreaseGGPStake(address stakerAddr, uint256 amount) internal { int256 stakerIndex = requireValidStaker(stakerAddr); subUint(keccak256(abi.encodePacked("staker.item", stakerIndex, ".ggpStaked")), amount); }
which calls subUint
function subUint(bytes32 key, uint256 amount) internal { gogoStorage.subUint(key, amount); }
which finally calls:
/// @param key The key for the record /// @param amount An amount to subtract from the record's value function subUint(bytes32 key, uint256 amount) external onlyRegisteredNetworkContract { uintStorage[key] = uintStorage[key] - amount; }
basically, a inflated duration setting can trigger arithmic underflow in
uintStorage[key] - amount
it is the staked GGP token from the node creator get slashed.
Now we need to see the POC in action:
In MiniPoolManager.t.sol
in test testRecordStakingEndWithSlash
the normal duration parameter is 2 weeks, if we comment out the 2 weeks and change the duration to 200 weeks
function testRecordStakingEndWithSlash() public { // uint256 duration = 2 weeks; uint256 duration = 200 weeks;
We run the test
forge test -vvv --match testRecordStakingEndWithSlash
the transaction revert in arithmic underflow
│ ├─ [6811] Staking::slashGGP(nodeOp: [0x0000000000000000000000000000000000050001], 383561643835616438356) │ │ ├─ [537] Storage::getAddress(0x817c4b3de237861e6f469d75ed1cda82d8bdda3182404c90d5f1beeef56e7190) [staticcall] │ │ │ └─ ← MinipoolManager: [0xdC0505A4c93647CD736E4bE24FBb171472bBFb10] │ │ ├─ [537] Storage::getAddress(0xcbd1c5b0c4d42cdfd729ee182f5c8992c7d923b770e4f43f682303afa9a8eb08) [staticcall] │ │ │ └─ ← Vault: [0xf5a2fE45F4f1308502b1C136b9EF8af136141382] │ │ ├─ [549] Storage::getUint(0xc8d6547c79d121d9c9e35f4887cbaef36c2766533faf1b1ef5b0898f0e91a456) [staticcall] │ │ │ └─ ← 1 │ │ ├─ [1016] Storage::subUint(0x1328d42ca0068ee293c0b73399873fd8b26ccb19d4419e75957733832b51665a, 383561643835616438356) │ │ │ └─ ← "Arithmetic over/underflow" │ │ └─ ← "Arithmetic over/underflow" │ └─ ← "Arithmetic over/underflow" └─ ← "Arithmetic over/underflow" Test result: FAILED. 0 passed; 1 failed; finished in 12.16ms
Manual Review
We recommend the project add reasonable boundary check and if the slashed amount is larger than the avaiable staked GGP token, slash all GGP + part of AVAX from node operator if possible to handle this case gracefully instead of triggering arithmic underflow.
#0 - c4-judge
2023-01-10T18:26:57Z
GalloDaSballo marked the issue as duplicate of #136
#1 - c4-judge
2023-02-03T19:40:43Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-08T09:49:36Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: __141345__
Also found by: 0xbepresent, Deivitto, HollaDieWaldfee, Nyx, PaludoX0, RaymondFam, ak1, cccz, ck, cryptostellar5, csanuragjain, ladboy233, rvierdiiev
68.0946 USDC - $68.09
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L328 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L89
Missing WhenNotPaused modifier for restakeGGP in Staking.sol
The admin can pause a contract in urgent sitation or the governance can pause a contract as they see fit.
/// @dev Verify contract is not paused modifier whenNotPaused() { string memory contractName = getContractName(address(this)); if (getBool(keccak256(abi.encodePacked("contract.paused", contractName)))) { revert ContractPaused(); } _; }
As we can see in the code, when the contract is paused, the stakeGGP is blocked in Staking.sol
/// @notice Accept a GGP stake /// @param amount The amount of GGP being staked function stakeGGP(uint256 amount) external whenNotPaused { // Transfer GGP tokens from staker to this contract ggp.safeTransferFrom(msg.sender, address(this), amount); _stakeGGP(msg.sender, amount); }
but the restakeGGP function is missing WhenNotPaused.
/// @notice Convenience function to allow for restaking claimed GGP rewards /// @param stakerAddr The C-chain address of a GGP staker in the protocol /// @param amount The amount of GGP being staked function restakeGGP(address stakerAddr, uint256 amount) public onlySpecificRegisteredContract("ClaimNodeOp", msg.sender) { // Transfer GGP tokens from the ClaimNodeOp contract to this contract ggp.safeTransferFrom(msg.sender, address(this), amount); _stakeGGP(stakerAddr, amount); }
Use can bypass the WhenNotPaused modifier by calling ClaimNodeOp#claimAndRestake
/// @notice Claim GGP and automatically restake the remaining unclaimed rewards /// @param claimAmt The amount of GGP the staker would like to withdraw from the protocol function claimAndRestake(uint256 claimAmt) external { Staking staking = Staking(getContractAddress("Staking")); uint256 ggpRewards = staking.getGGPRewards(msg.sender); if (ggpRewards == 0) { revert NoRewardsToClaim(); } if (claimAmt > ggpRewards) { revert InvalidAmount(); } staking.decreaseGGPRewards(msg.sender, ggpRewards); Vault vault = Vault(getContractAddress("Vault")); uint256 restakeAmt = ggpRewards - claimAmt; if (restakeAmt > 0) { vault.withdrawToken(address(this), ggp, restakeAmt); ggp.approve(address(staking), restakeAmt); staking.restakeGGP(msg.sender, restakeAmt); } if (claimAmt > 0) { vault.withdrawToken(msg.sender, ggp, claimAmt); } emit GGPRewardsClaimed(msg.sender, claimAmt); }
Manual Review
We recommend the project add the WhenNotPaused modifier to restakeGGP to not let user bypass the paused state
#0 - GalloDaSballo
2023-01-08T13:27:45Z
Pretty similar to others but I think Med is the most appropriate submission (will Judge severity later)
#1 - c4-judge
2023-01-08T13:27:50Z
GalloDaSballo marked the issue as primary issue
#2 - emersoncloud
2023-01-17T06:33:21Z
#3 - c4-judge
2023-01-29T18:15:30Z
GalloDaSballo marked the issue as duplicate of #673
#4 - c4-judge
2023-02-08T08:56:47Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: Jeiwan
Also found by: AkshaySrivastav, ladboy233, peritoflores
683.5268 USDC - $683.53
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L273 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L664
The multisig wallet owner is not able to cancel a miniPool
/// @notice Multisig can cancel a minipool if a problem was encountered *before* claimAndInitiateStaking() was called /// @param nodeID 20-byte Avalanche node ID /// @param errorCode The code that represents the reason for failure function cancelMinipoolByMultisig(address nodeID, bytes32 errorCode) external { int256 minipoolIndex = onlyValidMultisig(nodeID); setBytes32(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".errorCode")), errorCode); _cancelMinipoolAndReturnFunds(nodeID, minipoolIndex); }
which calls
/// @notice Cancels the minipool and returns the funds related to it /// @dev At this point we dont have any liq staker funds withdrawn from ggAVAX so no need to return them /// @param nodeID 20-byte Avalanche node ID /// @param index Index of the minipool function _cancelMinipoolAndReturnFunds(address nodeID, int256 index) private { requireValidStateTransition(index, MinipoolStatus.Canceled); setUint(keccak256(abi.encodePacked("minipool.item", index, ".status")), uint256(MinipoolStatus.Canceled)); address owner = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".owner"))); uint256 avaxNodeOpAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxNodeOpAmt"))); uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); Staking staking = Staking(getContractAddress("Staking")); staking.decreaseAVAXStake(owner, avaxNodeOpAmt); staking.decreaseAVAXAssigned(owner, avaxLiquidStakerAmt); staking.decreaseMinipoolCount(owner); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Canceled); Vault vault = Vault(getContractAddress("Vault")); vault.withdrawAVAX(avaxNodeOpAmt); owner.safeTransferETH(avaxNodeOpAmt); }
the owner is the creator of the miniPool, it could not only be a EOA account,
but also a smart contnract.
safeTransferETH assume that receiver must receive the ETH (AVAX). However, the owner could be a smart contract intentionally or intentionally that does not implement receive fallback function and does not accept ETH (AVAX), then the transaction revert and the caller is not able to complete the transaction cancelMinipoolByMultisig
Manual Review
We recommend the project let the owner of the miniPool claim the AVAX instead of sending the ETH out to them otherwise they could block the AVAX and revert the cancelMinipoolByMultisig transaction.
#0 - c4-judge
2023-01-10T07:50:55Z
GalloDaSballo marked the issue as duplicate of #623
#1 - c4-judge
2023-02-08T10:01:44Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: ladboy233
Also found by: __141345__, pauliax, peakbolt, rvierdiiev, unforgiven
479.8358 USDC - $479.84
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L229 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L156
Division by zero error can block RewardsPool#startRewardCycle if all multisig wallet is disabled.
A user needs to call the function startRewardsCycle in RewardsPool.sol
/// @notice Public function that will run a GGP rewards cycle if possible function startRewardsCycle() external {
which calls:
uint256 multisigClaimContractAllotment = getClaimingContractDistribution("ClaimMultisig"); uint256 nopClaimContractAllotment = getClaimingContractDistribution("ClaimNodeOp"); uint256 daoClaimContractAllotment = getClaimingContractDistribution("ClaimProtocolDAO"); if (daoClaimContractAllotment + nopClaimContractAllotment + multisigClaimContractAllotment > getRewardsCycleTotalAmt()) { revert IncorrectRewardsDistribution(); } TokenGGP ggp = TokenGGP(getContractAddress("TokenGGP")); Vault vault = Vault(getContractAddress("Vault")); if (daoClaimContractAllotment > 0) { emit ProtocolDAORewardsTransfered(daoClaimContractAllotment); vault.transferToken("ClaimProtocolDAO", ggp, daoClaimContractAllotment); } if (multisigClaimContractAllotment > 0) { emit MultisigRewardsTransfered(multisigClaimContractAllotment); distributeMultisigAllotment(multisigClaimContractAllotment, vault, ggp); } if (nopClaimContractAllotment > 0) { emit ClaimNodeOpRewardsTransfered(nopClaimContractAllotment); ClaimNodeOp nopClaim = ClaimNodeOp(getContractAddress("ClaimNodeOp")); nopClaim.setRewardsCycleTotal(nopClaimContractAllotment); vault.transferToken("ClaimNodeOp", ggp, nopClaimContractAllotment); }
We need to pay speical attention to the code block below:
if (multisigClaimContractAllotment > 0) { emit MultisigRewardsTransfered(multisigClaimContractAllotment); distributeMultisigAllotment(multisigClaimContractAllotment, vault, ggp); }
which calls:
/// @notice Distributes GGP to enabled Multisigs /// @param allotment Total GGP for Multisigs /// @param vault Vault contract /// @param ggp TokenGGP contract function distributeMultisigAllotment( uint256 allotment, Vault vault, TokenGGP ggp ) internal { MultisigManager mm = MultisigManager(getContractAddress("MultisigManager")); uint256 enabledCount; uint256 count = mm.getCount(); address[] memory enabledMultisigs = new address[](count); // there should never be more than a few multisigs, so a loop should be fine here for (uint256 i = 0; i < count; i++) { (address addr, bool enabled) = mm.getMultisig(i); if (enabled) { enabledMultisigs[enabledCount] = addr; enabledCount++; } } // Dirty hack to cut unused elements off end of return value (from RP) // solhint-disable-next-line no-inline-assembly assembly { mstore(enabledMultisigs, enabledCount) } uint256 tokensPerMultisig = allotment / enabledCount; for (uint256 i = 0; i < enabledMultisigs.length; i++) { vault.withdrawToken(enabledMultisigs[i], ggp, tokensPerMultisig); } }
the code distribute the reward to all multisig evenly.
uint256 tokensPerMultisig = allotment / enabledCount;
However, if the enabledCount is 0, meaning no multisig wallet is enabled, the transactionr revert in division by zero error and revert the startRewardsCycle transaction.
As showns in POC.
In RewardsPool.t.sol,
we change the name from testStartRewardsCycle to testStartRewardsCycle_POC
we add the code to disable all multisig wallet. before calling rewardsPool.startRewardsCycle
// disable all multisg wallet vm.prank(guardian); ocyticus.disableAllMultisigs();
Then we run the test
forge test -vvv --match testStartRewardsCycle_POC
the transaction revert in division by zero error, which block the startRewardsCycle
│ ├─ emit MultisigRewardsTransfered(value: 13499352589262561353689) │ ├─ [537] Storage::getAddress(0xcda836d09bcf3adcec2f52ddddeceac31738a574d5063511c887064e499593df) [staticcall] │ │ └─ ← MultisigManager: [0xA12E9172eB5A8B9054F897cC231Cd7a2751D6D93] │ ├─ [1313] MultisigManager::getCount() [staticcall] │ │ ├─ [549] Storage::getUint(0x778484468bc504108f077f6bf471293e4138c2d117c6f33607855518cf4bda79) [staticcall] │ │ │ └─ ← 1 │ │ └─ ← 1 │ ├─ [3050] MultisigManager::getMultisig(0) [staticcall] │ │ ├─ [537] Storage::getAddress(0xfebe6f39b65f18e050b53df1d0c8d45b8c5cce333324eb048b67b8ee5f26b7a3) [staticcall] │ │ │ └─ ← RialtoSimulator: [0x98D1613BC08756f51f46E841409E61C32f576F2f] │ │ ├─ [539] Storage::getBool(0x7ef800e7ca09c0c1063313b56290c06f6bc4bae0e9b7af3899bb7d5ade0403c8) [staticcall] │ │ │ └─ ← false │ │ └─ ← RialtoSimulator: [0x98D1613BC08756f51f46E841409E61C32f576F2f], false │ └─ ← "Division or modulo by 0" └─ ← "Division or modulo by 0" Test result: FAILED. 0 passed; 1 failed; finished in 11.64ms Failing tests: Encountered 1 failing test in test/unit/RewardsPool.t.sol:RewardsPoolTest [FAIL. Reason: Division or modulo by 0] testStartRewardsCycle_POC() (gas: 332890)
Manual Review
We recommend the project handle the case when the number of enabled multisig is 0 gracefully to not block the startRewardCycle transaction.
#0 - GalloDaSballo
2023-01-08T16:07:56Z
Best because of POC
#1 - c4-judge
2023-01-08T16:08:00Z
GalloDaSballo marked the issue as primary issue
#2 - GalloDaSballo
2023-02-01T19:41:00Z
The Warden has shown a scenario that could cause the call to startRewardsCycle
to revert.
When all multisigs are disabled (or no multisig is added), the division by zero will cause reverts.
While Admin Privilege is out of scope for this contest, the Warden has identified how a lack of zero-check can cause an open function to revert.
For this reason, I agree with Medium Severity
#3 - c4-judge
2023-02-08T08:30:57Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: ladboy233
Also found by: hansfriese
2194.0366 USDC - $2,194.04
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L560 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L676
The validation rewards can be inaccuartedly displayed to user and the slahsed amount can be wrong when slashing happens.
note the function below:
/// @notice Given a duration and an AVAX amt, calculate how much AVAX should be earned via validation rewards /// @param duration The length of validation in seconds /// @param avaxAmt The amount of AVAX the node staked for their validation period /// @return The approximate rewards the node should recieve from Avalanche for beign a validator function getExpectedAVAXRewardsAmt(uint256 duration, uint256 avaxAmt) public view returns (uint256) { ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 rate = dao.getExpectedAVAXRewardsRate(); return (avaxAmt.mulWadDown(rate) * duration) / 365 days; }
As outlined in the comment section, the function is intended to calculate how much AVAX should be earned via validation rewards
Besides display the reward, this function is also used in the function slash.
/// @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); }
note the code:
uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt);
the slashedGGPAmt is calculated based on the AVAX reward amount.
However, the estimation of the validation rewards is not accurate.
According to the doc:
https://docs.avax.network/nodes/build/set-up-an-avalanche-node-with-microsoft-azure
Running a validator and staking with Avalanche provides extremely competitive rewards of between 9.69% and 11.54% depending on the length you stake for.
This implies that the staking length affect staking rewards, but this is kind of vague. What is the exact implementation of the reward calculation?
The implementation is linked below:
https://github.com/ava-labs/avalanchego/blob/master/vms/platformvm/reward/calculator.go#L40
// Reward returns the amount of tokens to reward the staker with. // // RemainingSupply = SupplyCap - ExistingSupply // PortionOfExistingSupply = StakedAmount / ExistingSupply // PortionOfStakingDuration = StakingDuration / MaximumStakingDuration // MintingRate = MinMintingRate + MaxSubMinMintingRate * PortionOfStakingDuration // Reward = RemainingSupply * PortionOfExistingSupply * MintingRate * PortionOfStakingDuration func (c *calculator) Calculate(stakedDuration time.Duration, stakedAmount, currentSupply uint64) uint64 { bigStakedDuration := new(big.Int).SetUint64(uint64(stakedDuration)) bigStakedAmount := new(big.Int).SetUint64(stakedAmount) bigCurrentSupply := new(big.Int).SetUint64(currentSupply) adjustedConsumptionRateNumerator := new(big.Int).Mul(c.maxSubMinConsumptionRate, bigStakedDuration) adjustedMinConsumptionRateNumerator := new(big.Int).Mul(c.minConsumptionRate, c.mintingPeriod) adjustedConsumptionRateNumerator.Add(adjustedConsumptionRateNumerator, adjustedMinConsumptionRateNumerator) adjustedConsumptionRateDenominator := new(big.Int).Mul(c.mintingPeriod, consumptionRateDenominator) remainingSupply := c.supplyCap - currentSupply reward := new(big.Int).SetUint64(remainingSupply) reward.Mul(reward, adjustedConsumptionRateNumerator) reward.Mul(reward, bigStakedAmount) reward.Mul(reward, bigStakedDuration) reward.Div(reward, adjustedConsumptionRateDenominator) reward.Div(reward, bigCurrentSupply) reward.Div(reward, c.mintingPeriod) if !reward.IsUint64() { return remainingSupply } finalReward := reward.Uint64() if finalReward > remainingSupply { return remainingSupply } return finalReward }
note the reward calculation formula:
// Reward returns the amount of tokens to reward the staker with. // // RemainingSupply = SupplyCap - ExistingSupply // PortionOfExistingSupply = StakedAmount / ExistingSupply // PortionOfStakingDuration = StakingDuration / MaximumStakingDuration // MintingRate = MinMintingRate + MaxSubMinMintingRate * PortionOfStakingDuration // Reward = RemainingSupply * PortionOfExistingSupply * MintingRate * PortionOfStakingDuration
However, in the current ExpectedRewardAVA, the implementation is just:
AVAX reward rate * avax amount * duration / 365 days.
ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 rate = dao.getExpectedAVAXRewardsRate(); return (avaxAmt.mulWadDown(rate) * duration) / 365 days;
Clearly, the implementation of the avalanche side is more sophicated and accurate than the implemented ExpectedRewardAVA.
Manual Review
We recommend the project make the ExpectedRewardAVA implementation match the implement
https://github.com/ava-labs/avalanchego/blob/master/vms/platformvm/reward/calculator.go#L40
#0 - 0xju1ie
2023-01-20T20:09:30Z
Rialto is going to report the correct rewards rate to the DAO from Avalanche. Not sure if its a medium
#1 - emersoncloud
2023-01-20T20:26:14Z
We felt comfortable with a static setting number because we are (initally) staking minipools for 2 week increments with 2000 AVAX, making the variability in rewards rates minimal.
We will develop a more complex calculation as the protocol starts handling a wider range of funds and durations
#2 - GalloDaSballo
2023-02-02T20:52:18Z
The Warden has shown an incorrect implementation of the formula to estimate rewards.
The math would cause the slash value to be incorrect, causing improper yield to be distributed, for this reason I agree with Medium Severity
#3 - c4-judge
2023-02-02T20:52:24Z
GalloDaSballo marked the issue as selected for report
#4 - c4-judge
2023-02-02T21:12:38Z
GalloDaSballo marked the issue as primary issue
#5 - emersoncloud
2023-02-07T20:55:52Z
Acknowledged. See comments above!