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: 34/111
Findings: 5
Award: $635.40
π Selected for report: 0
π Solo Findings: 0
π Selected for report: immeas
Also found by: 0xdeadbeef0x, Allarious, Franfran, HollaDieWaldfee, Josiah, RaymondFam, SmartSek, ast3ros, unforgiven
484.3389 USDC - $484.34
If operator sets a long duration
slashing will likely revert, liquid stakers won't be compensated and operator will get to keep all his GGP
.
The fact ggp is inflationary significantly increases the likelihood the price will decrease over time.
If duration is long enough and the price of ggp drops than Operator will not have enough GGP to be slashed.
The code snippet below shows how the slash amount would be 200 ggp in a situation where operator only had 100 ggp to be slashed.
uint public slash; uint public expectedRewardAmount; function testPrice() external { // 1000 staked // GGP starting Price In Avax = 1 ether // getExpectedAVAXRewardsAmt(365 days, 1000) // staked = staked * 1e18;// 1000/10 = 100 uint duration = 365 days; uint liqStakersAmt = 1000 ether; expectedRewardAmount = (liqStakersAmt.mulWadDown(0.1 ether) * duration)/ 365 days; // calculateGGPSlashAmt(expectedRewardAmount) uint ggpPriceInAvax = 0.5 ether; slash = expectedRewardAmount.divWadDown(ggpPriceInAvax); }
setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration);
function calculateGGPSlashAmt(uint256 avaxRewardAmt) public view returns (uint256) { Oracle oracle = Oracle(getContractAddress("Oracle")); (uint256 ggpPriceInAvax, ) = oracle.getGGPPriceInAVAX(); return avaxRewardAmt.divWadDown(ggpPriceInAvax); }
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); }
function getExpectedAVAXRewardsAmt(uint256 duration, uint256 avaxAmt) public view returns (uint256) { ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 rate = dao.getExpectedAVAXRewardsRate(); //@audit should be 0.1 (10% annually) return (avaxAmt.mulWadDown(rate) * duration) / 365 days; }
decreaseGGPStake(stakerAddr, ggpAmt);
Use the validator duration of 14 days to slash ggp rewards.
#0 - c4-judge
2023-01-10T15:11:45Z
GalloDaSballo marked the issue as duplicate of #136
#1 - c4-judge
2023-02-03T19:39:32Z
GalloDaSballo marked the issue as not a duplicate
#2 - c4-judge
2023-02-03T19:53:19Z
GalloDaSballo marked the issue as duplicate of #493
#3 - c4-judge
2023-02-08T09:47:57Z
GalloDaSballo marked the issue as satisfactory
π 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
An attacker/early user can deposit 1 wei in the vault and increase the price per share by sending a very high value of the underlying directly to the vault, causing next vault depositors to:
function convertToShares(uint256 assets) public view virtual returns (uint256) { uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero. return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets()); } function convertToAssets(uint256 shares) public view virtual returns (uint256) { //@audit previewRedeem uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero. return supply == 0 ? shares : shares.mulDivDown(totalAssets(), supply); }
Send first LP tokens to address(0) as Uniswap does.
#0 - c4-judge
2023-01-08T13:11:39Z
GalloDaSballo marked the issue as duplicate of #209
#1 - c4-judge
2023-02-08T09:44:22Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: hansfriese
Also found by: 0Kage, 0xdeadbeef0x, Allarious, Aymen0909, Ch_301, Franfran, HollaDieWaldfee, Jeiwan, Nyx, RaymondFam, SmartSek, adriro, betweenETHlines, bin2chen, cccz, datapunk, immeas, kaliberpoziomka8552, peritoflores, wagmi
10.8565 USDC - $10.86
Recreating canceled pool is allowed even though it uses other operator's money.
If processed is accidentally repeated multiple times it can drain MinipoolManager.sol
When can pool be cancelled? - 5 day window after staking start time during Prelaunch state, set in create.
Pool is created
avaxStaked
= msg.value
= avaxNodeOpAmt
avaxAssigned
= avaxAssignmentRequest = msg.value
(should be 1:1 to msg.value
)Pool is canceled (within 5 days)
avaxStaked
= 0AVAX
is sent back to owner of minipool. This part is relevant because when pool is recreated AVAX
is not returned.Pool is recreated (Prelaunch)
avaxStaked
= avaxNodeRewardOpAmt
= 0
- avaxNodeRewardOpAmt = 0. avaxNodeRewardOpAmt is only set when calling RecordingStakingEndavaxNodeOpAmt
= previous msg.value when created. (even though the staking balance has been decreased)
msg.value
+ 0;avaxAssigned
= avaxNodeOpAmt
-Does this pass the collateralization ratio? - yes the GGPstaked is not affected.-- Extremely unlikely scenario: If steps above are repeated multiple times the MinipoolManager.sol balance will be drained. Continuing execution flow:
Rialto calls claimAndInitiateStaking
ggAVAX.withdrawForStaking(avaxLiquidStakerAmt);
still goes through. doesn't use funds it shouldntvault.withdrawAVAX(avaxNodeOpAmt);
totalAvaxAmt
is staked on Avalanche even though the avaxNodeOpAmt
half is not the operator's money.recordStakingStart
recordStakingEnd
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); }
function claimAndInitiateStaking(address nodeID) external { int256 minipoolIndex = onlyValidMultisig(nodeID); requireValidStateTransition(minipoolIndex, MinipoolStatus.Launched); uint256 avaxNodeOpAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt"))); uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt"))); // Transfer funds to this contract and then send to multisig ggAVAX.withdrawForStaking(avaxLiquidStakerAmt); addUint(keccak256("MinipoolManager.TotalAVAXLiquidStakerAmt"), avaxLiquidStakerAmt); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Launched)); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Launched); Vault vault = Vault(getContractAddress("Vault")); vault.withdrawAVAX(avaxNodeOpAmt); uint256 totalAvaxAmt = avaxNodeOpAmt + avaxLiquidStakerAmt; msg.sender.safeTransferETH(totalAvaxAmt); }
function recreateMinipool(address nodeID) external whenNotPaused { int256 minipoolIndex = onlyValidMultisig(nodeID); requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); Minipool memory mp = getMinipool(minipoolIndex); // Compound the avax plus rewards // NOTE Assumes a 1:1 nodeOp:liqStaker funds ratio uint256 compoundedAvaxNodeOpAmt = mp.avaxNodeOpAmt + mp.avaxNodeOpRewardAmt; setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), compoundedAvaxNodeOpAmt); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), compoundedAvaxNodeOpAmt); 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.increaseMinipoolCount(mp.owner); if (staking.getRewardsStartTime(mp.owner) == 0) {/ // Edge case where calculateAndDistributeRewards has reset their rewards time even though they are still cycling // So we re-set it here to their initial start time for this minipool staking.setRewardsStartTime(mp.owner, mp.initialStartTime); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 ratio = staking.getCollateralizationRatio(mp.owner); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } resetMinipoolData(minipoolIndex); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); }
Do not allow canceled Minipools to be recreated.
#0 - c4-judge
2023-01-10T20:56:09Z
GalloDaSballo marked the issue as duplicate of #484
#1 - GalloDaSballo
2023-01-10T20:56:17Z
Dup of #484 without front-run, awarding half
#2 - c4-judge
2023-01-10T20:56:21Z
GalloDaSballo marked the issue as partial-50
#3 - c4-judge
2023-02-03T12:41:11Z
GalloDaSballo marked the issue as duplicate of #569
#4 - c4-judge
2023-02-09T08:45:54Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#5 - Simon-Busch
2023-02-09T12:33:58Z
Changed severity back to M as requested by @GalloDaSballo
π Selected for report: 0xbepresent
Also found by: 0xdeadbeef0x, Breeje, Franfran, IllIllI, Matin, Rolezn, SmartSek, btk, ck, datapunk, fs0c, nadin, rvierdiiev, unforgiven
57.1995 USDC - $57.20
Users might not be able to withdraw funds that they are entitled to and are present in the contract.
depositFromStaking()
deposits rewards in Staking.sol
, however users might not be able withdraw or redeem the amount their entitled to because totalReleasedAssets
has not been updated with the new asset.balanceOf(address(this))
after depositFromStaking()
was called.
Users will need to wait until rewardsCycleEnd
to call syncRewards
to update totalReleasedAssets
with the current asset.balanceOf(address(this))
.
function depositFromStaking(uint256 baseAmt, uint256 rewardAmt) public payable onlySpecificRegisteredContract("MinipoolManager", msg.sender) { uint256 totalAmt = msg.value; if (totalAmt != (baseAmt + rewardAmt) || baseAmt > stakingTotalAssets) { revert InvalidStakingDeposit(); } emit DepositedFromStaking(msg.sender, baseAmt, rewardAmt); stakingTotalAssets -= baseAmt; IWAVAX(address(asset)).deposit{value: totalAmt}(); }
function beforeWithdraw( uint256 amount, uint256 /* shares */ ) internal override { totalReleasedAssets -= amount; }
function syncRewards() public { uint32 timestamp = block.timestamp.safeCastTo32(); if (timestamp < rewardsCycleEnd) { revert SyncError(); } uint192 lastRewardsAmt_ = lastRewardsAmt; uint256 totalReleasedAssets_ = totalReleasedAssets; uint256 stakingTotalAssets_ = stakingTotalAssets; uint256 nextRewardsAmt = (asset.balanceOf(address(this)) + stakingTotalAssets_) - totalReleasedAssets_ - lastRewardsAmt_; // Ensure nextRewardsCycleEnd will be evenly divisible by `rewardsCycleLength`. uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength; lastRewardsAmt = nextRewardsAmt.safeCastTo192(); lastSync = timestamp; rewardsCycleEnd = nextRewardsCycleEnd; totalReleasedAssets = totalReleasedAssets_ + lastRewardsAmt_; emit NewRewardsCycle(nextRewardsCycleEnd, nextRewardsAmt); }
Update totalReleasedAssets
in depositFromStaking()
Note: No funds here are at permanent risk, however because liquidity for stakers is a crucial part of the protocol I believe this to be a high severity.
#0 - c4-judge
2023-01-10T17:49:50Z
GalloDaSballo marked the issue as duplicate of #317
#1 - c4-judge
2023-02-08T19:29:44Z
GalloDaSballo marked the issue as satisfactory
#2 - c4-judge
2023-02-08T19:30:51Z
GalloDaSballo changed the severity to 2 (Med Risk)
π Selected for report: HollaDieWaldfee
Also found by: Allarious, Aymen0909, Saintcode_, SmartSek, adriro, bin2chen, cccz, kaliberpoziomka8552, minhtrng, rvierdiiev, sces60107, sk8erboy, wagmi
68.0946 USDC - $68.09
Scenario (Impact) 1: Operator will be eligible for rewards even though he didnt't earn any. This will lead Rialto not being able to distribute rewards to some operator's due a difference in actual rewards and rewards to be distributed.
createMinipool()
recordStakingStart()
(status: Staking) -> increaseAVAXAssignedHighWater
is calledrecordStakingError()
withdrawMinipoolFunds
and get his AVAX back (minipool.count
is not decreased).increaseAVAXAssignedHighWater
has not been decreased yet.calculateAndDistributeRewards()
.
- Because minipool.count
count has not been decreased and therefore, rewardsStartTime
is still unchanged Operator is still eligible.
- Operator gets GGP reward even though he could have received no AVAX rewards neither staked for any significant period of time to earn his rewards.
- resetAVAXAssignedHighWater()
is called so this will not repeat and next cycle GGP
rewards will be zero if nothing else changes (thankfully).Scenario (Impact) 2:
isEligible()
returns true
when it should return false
instead. Leading to Rialto wasting gas by calling calculateAndDistributeRewards()
mulitple times to distribute 0 rewards for Operator that have not staked during the current reward cycle.
Potentially even leading to a DOS depending on the Rialto function logic that calls calculateAndDistributeRewards()
for every Operator.
createMinipool()
claimAndInitiateStaking()
recordStakingError()
withdrawMinipoolFunds()
(optional)staking.decreaseMinipoolCount()
is never calledcalculateAndDistributeRewards()
.
- Because minipool.count
count has not been decreased and therefore, rewardsStartTime
is still unchanged Operator is still eligible.decreaseMiniPoolCount
is only called in recordStakingEnd()
and _cancelMinipoolAndReturnFunds()
Pools can't be canceled unless if in Prelaunch
status.
if (currentStatus == MinipoolStatus.Prelaunch) { isValid = (to == MinipoolStatus.Launched || to == MinipoolStatus.Canceled); ``` Consequently every pool that roughly follows the path of `Error`->`Finished` and does not reach the stage where Rialto calls `recordStakingEnd()` can not be canceled. This means `minipool.count` will never be decreased even after `withdrawMinipoolFunds()` is called for `Error` status pools. `minipool.count` will then never be equal zero even when pool is `Finished`. Consequently `rewardsStartTime` will never be reset to zero when Rialto calls `calculateAndDistributeRewards`. ``` uint256 minipoolCount = staking.getMinipoolCount(stakerAddr); if (minipoolCount == 0) { staking.setRewardsStartTime(stakerAddr, 0); } ``` Rialto will end up calling `calculateAndDistributeRewards()` mulitple times to distribute zero rewards to every Operator that has no real active pools, but rather in a permanent `Error` or `Finished` status. Potentially also leading to a DOS depending on the function logic that calls `calculateAndDistributeRewards()` for every Operator. This might also lead to attempting the distribution of rewards for an amount greater than the amount of real rewards generated. Consequently some user will not be able to be compensated fairly. https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L152-L175 ```solidity function requireValidStateTransition(int256 minipoolIndex, MinipoolStatus to) private view { bytes32 statusKey = keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")); MinipoolStatus currentStatus = MinipoolStatus(getUint(statusKey)); bool isValid; //@audit come back to this and check if every state transition is done properly! if (currentStatus == MinipoolStatus.Prelaunch) { isValid = (to == MinipoolStatus.Launched || to == MinipoolStatus.Canceled); } else if (currentStatus == MinipoolStatus.Launched) { isValid = (to == MinipoolStatus.Staking || to == MinipoolStatus.Error); } else if (currentStatus == MinipoolStatus.Staking) { isValid = (to == MinipoolStatus.Withdrawable || to == MinipoolStatus.Error); } else if (currentStatus == MinipoolStatus.Withdrawable || currentStatus == MinipoolStatus.Error) { isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Prelaunch); } else if (currentStatus == MinipoolStatus.Finished || currentStatus == MinipoolStatus.Canceled) { // Once a node is finished/canceled, if they re-validate they go back to beginning state isValid = (to == MinipoolStatus.Prelaunch); } else { isValid = false; } if (!isValid) { revert InvalidStateTransition(); } } ``` https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L56-L85 ```solidity function calculateAndDistributeRewards(address stakerAddr, uint256 totalEligibleGGPStaked) external onlyMultisig { Staking staking = Staking(getContractAddress("Staking")); staking.requireValidStaker(stakerAddr); RewardsPool rewardsPool = RewardsPool(getContractAddress("RewardsPool")); if (rewardsPool.getRewardsCycleCount() == 0) { revert RewardsCycleNotStarted(); } if (staking.getLastRewardsCycleCompleted(stakerAddr) == rewardsPool.getRewardsCycleCount()) { revert RewardsAlreadyDistributedToStaker(stakerAddr); } staking.setLastRewardsCycleCompleted(stakerAddr, rewardsPool.getRewardsCycleCount()); uint256 ggpEffectiveStaked = staking.getEffectiveGGPStaked(stakerAddr); uint256 percentage = ggpEffectiveStaked.divWadDown(totalEligibleGGPStaked); uint256 rewardsCycleTotal = getRewardsCycleTotal(); uint256 rewardsAmt = percentage.mulWadDown(rewardsCycleTotal); if (rewardsAmt > rewardsCycleTotal) { revert InvalidAmount(); } staking.resetAVAXAssignedHighWater(stakerAddr); staking.increaseGGPRewards(stakerAddr, rewardsAmt); // check if their rewards time should be reset uint256 minipoolCount = staking.getMinipoolCount(stakerAddr); if (minipoolCount == 0) { staking.setRewardsStartTime(stakerAddr, 0); } } ``` https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L44-L52 ```solidity /// @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()); } ``` ## Recommended Mitigation Steps Call `decreaseMinipoolCount()` whenever pool is likely to be created or recreated or it's current status can be updated to `Prelaunch`. Alternatively for `Error` and `Finished` pools to be canceled via valid state transition.
#0 - c4-sponsor
2023-01-12T20:20:24Z
emersoncloud marked the issue as sponsor confirmed
#1 - c4-sponsor
2023-01-12T20:21:41Z
emersoncloud marked the issue as disagree with severity
#2 - emersoncloud
2023-01-12T20:23:01Z
We agree this is an issue but not high severity, assets aren't stolen lost or compromised directly. Instead the protocol could leak value through this function of the protocol
#3 - 0xju1ie
2023-01-16T21:08:06Z
Scenario 1 would not happen.
Operator calls createMinipool() Rialto calls recordStakingStart() (status: Staking) -> increaseAVAXAssignedHighWater is called Operator somehow immediately triggers Rialto to call recordStakingError()
Rialto would call claimAndInitiateStaking() and then recordStakingError() if there was an error trying to stake or recordStakingStart() if the stake was successful.
Scenario 2 would happen, but it would not result in extra rewards in the given scenario since they have not increased their avaxAssignedHighWater and thus they would be eligible but would not get any rewards
#4 - 0xju1ie
2023-01-19T16:08:02Z
scenario 1 is invalid. scenario 2 is dupe of https://github.com/code-423n4/2022-12-gogopool-findings/issues/220
#5 - c4-judge
2023-01-31T15:16:53Z
GalloDaSballo marked the issue as duplicate of #235
#6 - c4-judge
2023-02-08T19:42:54Z
GalloDaSballo marked the issue as satisfactory
#7 - c4-judge
2023-02-08T19:43:07Z
GalloDaSballo changed the severity to 2 (Med Risk)