GoGoPool contest - hansfriese's results

Liquid staking for Avalanche.

General Information

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

GoGoPool

Findings Distribution

Researcher Performance

Rank: 4/111

Findings: 8

Award: $3,053.20

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: hansfriese

Also found by: Allarious, HollaDieWaldfee, betweenETHlines, chaduke, unforgiven, wagmi

Labels

bug
3 (High Risk)
primary issue
selected for report
sponsor confirmed
fix security (sponsor)
H-01

Awards

1233.8635 USDC - $1,233.86

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L374

Vulnerability details

Impact

Node operators can manipulate the assigned high water to be higher than the actual.

Proof of Concept

The protocol rewards node operators according to the AVAXAssignedHighWater that is the maximum amount assigned to the specific staker during the reward cycle. In the function MinipoolManager.recordStakingStart(), the AVAXAssignedHighWater is updated as below.

MinipoolManager.sol
349: 	function recordStakingStart(
350: 		address nodeID,
351: 		bytes32 txID,
352: 		uint256 startTime
353: 	) external {
354: 		int256 minipoolIndex = onlyValidMultisig(nodeID);
355: 		requireValidStateTransition(minipoolIndex, MinipoolStatus.Staking);
356: 		if (startTime > block.timestamp) {
357: 			revert InvalidStartTime();
358: 		}
359:
360: 		setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Staking));
361: 		setBytes32(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".txID")), txID);
362: 		setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".startTime")), startTime);
363:
364: 		// If this is the first of many cycles, set the initialStartTime
365: 		uint256 initialStartTime = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")));
366: 		if (initialStartTime == 0) {
367: 			setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), startTime);
368: 		}
369:
370: 		address owner = getAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")));
371: 		uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")));
372: 		Staking staking = Staking(getContractAddress("Staking"));
373: 		if (staking.getAVAXAssignedHighWater(owner) < staking.getAVAXAssigned(owner)) {
374: 			staking.increaseAVAXAssignedHighWater(owner, avaxLiquidStakerAmt);//@audit wrong
375: 		}
376:
377: 		emit MinipoolStatusChanged(nodeID, MinipoolStatus.Staking);
378: 	}

In the line #373, if the current assigned AVAX is greater than the owner's AVAXAssignedHighWater, it is increased by avaxLiquidStakerAmt. But this is supposed to be updated to staking.getAVAXAssigned(owner) rather than being increased by the amount.

Example: The node operator creates a minipool with 1000AVAX via createMinipool(nodeID, 2 weeks, delegationFee, 1000*1e18). On creation, the assigned AVAX for the operator will be 1000AVAX. If the Rialtor calls recordStakingStart(), AVAXAssignedHighWater will be updated to 1000AVAX. After the validation finishes, the operator creates another minipool with 1500AVAX this time. Then on recordStakingStart(), AVAXAssignedHighWater will be updated to 2500AVAX by increasing 1500AVAX because the current assigned AVAX is 1500AVAX which is higher than the current AVAXAssignedHighWater=1000AVAX. This is wrong because the actual highest assigned amount is 1500AVAX. Note that AVAXAssignedHighWater is reset only through the function calculateAndDistributeRewards which can be called after RewardsCycleSeconds=28 days.

Tools Used

Manual Review

Call staking.resetAVAXAssignedHighWater(owner) instead of calling increaseAVAXAssignedHighWater().

MinipoolManager.sol
373: 		if (staking.getAVAXAssignedHighWater(owner) < staking.getAVAXAssigned(owner)) {
374: 			staking.resetAVAXAssignedHighWater(owner); //@audit update to the current AVAX assigned
375: 		}

#0 - c4-judge

2023-01-09T20:35:59Z

GalloDaSballo marked the issue as primary issue

#1 - GalloDaSballo

2023-01-09T20:36:02Z

Best description

#2 - iFrostizz

2023-01-28T17:37:09Z

Can we take some extra considerations here please ? Discussed with @0xju1ie about this specific issue, and this was the answer:

(it is AVAXAssignedHighWater)

It increases on a per minipool basis right now, increasing based on only what that single minipool is getting. If it was to just update the AVAXAssignedHighWater to getAVAXAssigned, then it could be assigning the highwater mark too early. EX for how it is now: 1. create minipool1, assignedAvax = 1k, high water= 0 2. create minipool2, assignedAvax =1k, high water = 0 3. record start for minipool1, highwater -> 1k 4. record start for minipool2, highwater -> 2k EX for how your suggestion could be exploited: 1. create minipool1, assignedAvax = 1k, high water= 0 2. create minipool2, assignedAvax =1k, high water = 0 3. record start for minipool1, highwater -> 2k 4. cancel minipool2, highwater -> 2k if we used only avax assigned for that case then it would mess up the collateralization ratio for the second minipool and they would only get paid for the minipool that they are currently operating, not the one that ended previously.

#3 - GalloDaSballo

2023-01-29T18:48:34Z

Making primary under advice of the sponsor

#4 - 0xju1ie

2023-02-01T10:04:00Z

Their example in the proof of concept section is correct, and we have decided that this is not the ideal behavior and thus this is a bug. However, their recommended mitigation steps would create other issues, as highlighted by what @iFrostizz said. We intend to solve this issue differently than what they suggested.

#5 - GalloDaSballo

2023-02-02T20:42:38Z

The Warden has shown a flaw in the way increaseAVAXAssignedHighWater is used, which can be used to:

  • Inflate the amount of AVAX
  • With the goal of extracting more rewards than intended

I believe that the finding highlights both a way to extract further rewards as well as broken accounting

For this reason I agree with High Severity

#6 - c4-judge

2023-02-08T08:29:53Z

GalloDaSballo marked the issue as selected for report

Findings Information

🌟 Selected for report: bin2chen

Also found by: AkshaySrivastav, RaymondFam, caventa, cozzetti, csanuragjain, hansfriese, rvierdiiev, shark

Labels

bug
3 (High Risk)
satisfactory
duplicate-532

Awards

597.9492 USDC - $597.95

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L682 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L379

Vulnerability details

Impact

Slashed GGP tokens are trapped and the victim liquid stakers do not get compensation.

Proof of Concept

If the node did not operate well (uptime less than 80%) the validation node does not get any rewards from Avalanche and the protocol tries to punish the node operator and compensate the victim liquid stakers through a slash process.

MinipoolManager.sol
670: 	function slash(int256 index) private {
671: 		address nodeID = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".nodeID")));
672: 		address owner = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".owner")));
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);
677: 		setUint(keccak256(abi.encodePacked("minipool.item", index, ".ggpSlashAmt")), slashGGPAmt);
678:
679: 		emit GGPSlashed(nodeID, slashGGPAmt);
680:
681: 		Staking staking = Staking(getContractAddress("Staking"));
682: 		staking.slashGGP(owner, slashGGPAmt);
683: 	}

Staking.sol
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);//@audit slashed ggp should be used to compensate liquid stakers
383: 	}

But if we look at the function Staking.slashGGP(), the slashed GGP tokens are transferred to ProtocolDao contract and there is no function exposed in ProtocolDao contract (or any other place) to get these GGP tokens out. Furthermore, these GGP tokens are supposed to be used to compensate the liquid stakers, so I believe the protocol intends to exchange GGP into AVAX in some way and transfers to TokenggAVAX contract so that the liquid stakers can claim later.

Tools Used

Manual Review

Implement a functionality to exchange GGP into AVAX and put them into the TokenggAVAX contract.

#0 - c4-judge

2023-01-09T09:49:27Z

GalloDaSballo marked the issue as duplicate of #532

#1 - c4-judge

2023-02-09T07:52:44Z

GalloDaSballo marked the issue as unsatisfactory: Invalid

#2 - GalloDaSballo

2023-02-09T07:52:48Z

Dup of Dup / Bug

#3 - c4-judge

2023-02-09T07:53:14Z

GalloDaSballo marked the issue as satisfactory

#4 - GalloDaSballo

2023-02-09T07:53:23Z

Marking as dup of same finding script will handle awards

Findings Information

🌟 Selected for report: bin2chen

Also found by: AkshaySrivastav, RaymondFam, caventa, cozzetti, csanuragjain, hansfriese, rvierdiiev, shark

Labels

bug
3 (High Risk)
satisfactory
duplicate-532

Awards

597.9492 USDC - $597.95

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L379

Vulnerability details

Impact

Slashed GGP tokens are trapped in the ProtocolDAO contract and liquid stakers are not compensated.

Proof of Concept

The protocol has a mechanism to slash GGP tokens when the nodes did not operate well (uptime less than 80%). But looking at the function slashGGP(), the slashed GGP tokens are transferred to the ProtocolDAO contract and there is no way to transfer/spend those. I believe it is supposed to be sent to ClaimProtocolDAO contract, not the ProtocolDAO, so that it can be spent later. Furthermore, I don't see any mechanism to compensate the victim liquid stakers. I believe the protocol intends to exchange the slashed GGP tokens into AVAX and put into the TokenggAVAX so that liquid stakers can claim.

Staking.sol
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);//@audit wrong contract?
383: 	}

Tools Used

Manual Review

  • Transfer the slashed GGP tokens to ClaimProtocolDAO contract so that the protocol can spend later.
  • Implement a mechanism to compensate the liquid stakers when the node operator's GGP collateral is slashed.

#0 - c4-judge

2023-01-09T09:49:25Z

GalloDaSballo marked the issue as duplicate of #532

#1 - c4-judge

2023-02-09T07:51:22Z

GalloDaSballo marked the issue as satisfactory

Awards

14.9051 USDC - $14.91

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-209

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L129 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L99

Vulnerability details

Impact

First depositor can manipulate the share price and prevent small deposits for other LPs

Proof of Concept

TokenggAVAX implemented ERC4626 and there is a well-known attack vector for ERC4626 (See here) The first depositor puts 1wei of AVAX as the first deposit and gets 1wei of ggAVAX share. Then he sends an enormous amounts of AVAX and the price of share becomes very big. Although the impact is not immediate becuase of the xERC4626 implementation, it will affect slowly through the 14 days.(rewardsCycleLength) So following depositors needs to put high amounts to avoid getting zero shares. This protocol has a restriction to allow only positive shares so the attacker does not get profits from the following depositors. But it's still problematic because the LPs are limited in the deposit amount.

Tools Used

Manual Review

  • Require the first deposit amount to be more than some thresholds.
  • Consider minting some ggAVAX to zero address on initialization. (Like Uniswap V2)

#0 - c4-judge

2023-01-08T13:11:48Z

GalloDaSballo marked the issue as duplicate of #209

#1 - c4-judge

2023-01-29T18:38:37Z

GalloDaSballo changed the severity to 3 (High Risk)

#2 - c4-judge

2023-02-08T09:44:31Z

GalloDaSballo marked the issue as satisfactory

Findings Information

Labels

bug
2 (Med Risk)
satisfactory
duplicate-648

Awards

145.3017 USDC - $145.30

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L98 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L66

Vulnerability details

Impact

GGP tokens will be inflated more slowly than planned

Proof of Concept

The protocol is going to inflate GGP tokens 5% per year and reward the node operators, multisig operators and the protocol DAO. For this, the protocol calculates the daily inflation rate and use that for inflation.

ProtocolDAO.sol
39: 		// GGP Inflation
40: 		setUint(keccak256("ProtocolDAO.InflationIntervalSeconds"), 1 days);
41: 		setUint(keccak256("ProtocolDAO.InflationIntervalRate"), 1000133680617113500); // 5% annual calculated on a daily interval

The inflation interval is set to 1 day (default) and the public function startRewardsCycle() is supposd to be called (by anyone) to cause inflation. Looking at the function inflate(), we can see that the InflationIntervalStartTime is updated to the current block.timestamp.

function inflate() internal {
  ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO"));
  uint256 inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime());
  (uint256 currentTotalSupply, uint256 newTotalSupply) = getInflationAmt();

  TokenGGP ggp = TokenGGP(getContractAddress("TokenGGP"));
  if (newTotalSupply > ggp.totalSupply()) {
    revert MaximumTokensReached();
  }

  uint256 newTokens = newTotalSupply - currentTotalSupply;

  emit GGPInflated(newTokens);

  dao.setTotalGGPCirculatingSupply(newTotalSupply);

  addUint(keccak256("RewardsPool.InflationIntervalStartTime"), inflationIntervalElapsedSeconds);//@audit equal to setting as block.timestamp
  setUint(keccak256("RewardsPool.RewardsCycleTotalAmt"), newTokens);
}

But the actual reward is calculated based on the number of intervals, not the exact seconds.

function getInflationIntervalsElapsed() public view returns (uint256) {
  ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO"));
  uint256 startTime = getInflationIntervalStartTime();
  if (startTime == 0) {
    revert ContractHasNotBeenInitialized();
  }
  return (block.timestamp - startTime) / dao.getInflationIntervalSeconds();//@audit-info rounded to days
}

It means the actual inflation was done for the time getInflationIntervalsElapsed() * getInflationIntervalSeconds() which is different from (always equal or less than) block.timestamp - getInflationIntervalStartTime().

So the InflationIntervalStartTime should be increased to the actual time used for calculation (getInflationIntervalsElapsed() * getInflationIntervalSeconds()) or else the inflation will happen more slowly than planned.

Tools Used

Manual Review

Change the function inflate() as below.

function inflate() internal {
  ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO"));
  uint256 inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime());
  (uint256 currentTotalSupply, uint256 newTotalSupply) = getInflationAmt();

  TokenGGP ggp = TokenGGP(getContractAddress("TokenGGP"));
  if (newTotalSupply > ggp.totalSupply()) {
    revert MaximumTokensReached();
  }

  uint256 newTokens = newTotalSupply - currentTotalSupply;

  emit GGPInflated(newTokens);

  dao.setTotalGGPCirculatingSupply(newTotalSupply);

  addUint(keccak256("RewardsPool.InflationIntervalStartTime"), getInflationIntervalsElapsed() * getInflationIntervalSeconds()); //@audit fix here
  setUint(keccak256("RewardsPool.RewardsCycleTotalAmt"), newTokens);current cycle
}

#0 - c4-judge

2023-01-10T10:26:05Z

GalloDaSballo marked the issue as duplicate of #648

#1 - c4-judge

2023-02-08T10:02:29Z

GalloDaSballo marked the issue as satisfactory

Awards

28.2268 USDC - $28.23

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
selected for report
sponsor confirmed
fix security (sponsor)
M-09

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L444

Vulnerability details

Impact

Minipools can be created using other operator's AVAX deposit via recreateMinipool

Proof of Concept

This issue is related to state transition of Minipools. According to the implementation, the possible states and transitions are as below.

Imgur

The Rialto may call recreateMinipool when the minipool is in states of Withdrawable, Finished, Error, Canceled. The problem is that these four states are not the same in the sense of holding the node operator's AVAX. If the state flow has followed Prelaunch->Launched->Staking->Error, all the AVAX are still in the vault. If the state flow has followed Prelaunch->Launched->Staking->Error->Finished (last transition by withdrawMinipoolFunds), all the AVAX are sent back to the node operator. So if the Rialto calls recreateMinipool for the second case, there are no AVAX deposited from the node operator at that point but there can be AVAX from other mini pools in the state of Prelaunch. Because there are AVAX in the vault (and these are not managed per staker base), recreatePool results in a new mini pool in Prelaunch state and it is further possible to go through the normal flow Prelaunch->Launched->Staking->Withdrawable->Finished. And the other minipool that was waiting for launch will not be able to launch because the vault is lack of AVAX.

Below is a test case written to show an example.

function testAudit() public {
  uint256 duration = 2 weeks;
  uint256 depositAmt = 1000 ether;
  uint256 avaxAssignmentRequest = 1000 ether;
  uint256 validationAmt = depositAmt + avaxAssignmentRequest;
  uint128 ggpStakeAmt = 200 ether;

  // Node Op 1
  vm.startPrank(nodeOp);
  ggp.approve(address(staking), MAX_AMT);
  staking.stakeGGP(ggpStakeAmt);
  MinipoolManager.Minipool memory mp1 = createMinipool(
    depositAmt,
    avaxAssignmentRequest,
    duration
  );
  vm.stopPrank();

  // Node Op 2
  address nodeOp2 = getActorWithTokens("nodeOp2", MAX_AMT, MAX_AMT);
  vm.startPrank(nodeOp2);
  ggp.approve(address(staking), MAX_AMT);
  staking.stakeGGP(ggpStakeAmt);
  MinipoolManager.Minipool memory mp2 = createMinipool(
    depositAmt,
    avaxAssignmentRequest,
    duration
  );
  vm.stopPrank();

  int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID);

  address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT);
  vm.prank(liqStaker1);
  ggAVAX.depositAVAX{ value: MAX_AMT }();

  // Node Op 1: Prelaunch->Launched
  vm.prank(address(rialto));
  minipoolMgr.claimAndInitiateStaking(mp1.nodeID);

  bytes32 txID = keccak256("txid");
  vm.prank(address(rialto));
  // Node Op 1: Launched->Staking
  minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp);

  vm.prank(address(rialto));
  // Node Op 1: Staking->Error
  bytes32 errorCode = "INVALID_NODEID";
  minipoolMgr.recordStakingError{ value: validationAmt }(mp1.nodeID, errorCode);

  // There are 2*depositAmt AVAX in the pool manager
  assertEq(vault.balanceOf("MinipoolManager"), depositAmt * 2);

  // Node Op 1: Staking->Finished withdrawing funds
  vm.prank(nodeOp);
  minipoolMgr.withdrawMinipoolFunds(mp1.nodeID);
  assertEq(staking.getAVAXStake(nodeOp), 0);
  mp1 = minipoolMgr.getMinipool(minipoolIndex);
  assertEq(mp1.status, uint256(MinipoolStatus.Finished));

  // Rialto recreate a minipool for the mp1
  vm.prank(address(rialto));
  // Node Op 1: Finished -> Prelaunch
  minipoolMgr.recreateMinipool(mp1.nodeID);

  assertEq(vault.balanceOf("MinipoolManager"), depositAmt);

  // Node Op 1: Prelaunch -> Launched
  vm.prank(address(rialto));
  minipoolMgr.claimAndInitiateStaking(mp1.nodeID);

  // Node Op 1: Launched -> Staking
  vm.prank(address(rialto));
  minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp);

  assertEq(staking.getAVAXStake(nodeOp), 0);
  assertEq(staking.getAVAXAssigned(nodeOp), depositAmt);
  assertEq(staking.getAVAXAssignedHighWater(nodeOp), depositAmt);

  // now try to launch the second operator's pool, it will fail with InsufficientContractBalance
  vm.prank(address(rialto));
  vm.expectRevert(Vault.InsufficientContractBalance.selector);
  minipoolMgr.claimAndInitiateStaking(mp2.nodeID);
}

Tools Used

Manual Review, Foundry

Make sure to keep the node operator's deposit status the same for all states that can lead to the same state. For example, for all states that can transition to Prelaunch, make sure to send the AVAX back to the user and get them back on the call recreateMiniPool().

#0 - c4-judge

2023-01-10T17:24:12Z

GalloDaSballo marked the issue as primary issue

#1 - 0xju1ie

2023-01-30T12:13:03Z

I think #213 might be a better primary. This one primarily depends on minipools going to staking->error which wouldn't actually happen unless Rialto made a mistake

#2 - c4-judge

2023-01-31T13:23:01Z

GalloDaSballo marked the issue as duplicate of #213

#3 - c4-judge

2023-02-03T12:31:32Z

GalloDaSballo marked the issue as not a duplicate

#4 - c4-judge

2023-02-03T12:31:48Z

GalloDaSballo marked the issue as primary issue

#5 - c4-judge

2023-02-03T12:33:28Z

GalloDaSballo changed the severity to 2 (Med Risk)

#6 - GalloDaSballo

2023-02-03T12:35:49Z

The Warden has shown an issue with the FSM, Pools are allowed to perform the following transition Prelaunch->Launched->Staking->Error->Finished->Prelaunch which allows to spin up the pool without funds

This could only happen if Rialto performs a mistake, so the finding is limited to highlighting the issue with the State Transition

For this reason, I believe Medium to be the most appropriate Severity

#7 - iFrostizz

2023-02-03T13:39:42Z

@GalloDaSballo Please note that the issue not limited to Rialto doing a mistake, but it's actually possible to trick it by frontrunning the Rialto transaction as outlined in my finding: https://github.com/code-423n4/2022-12-gogopool-findings/issues/484#issuecomment-1382584856 That's why the high severity was chosen initially.

#8 - c4-judge

2023-02-08T08:28:18Z

GalloDaSballo marked the issue as selected for report

#9 - c4-judge

2023-02-09T08:45:54Z

GalloDaSballo changed the severity to QA (Quality Assurance)

#10 - Simon-Busch

2023-02-09T12:29:23Z

Changed severity back to M as requested by @GalloDaSballo

Findings Information

Labels

bug
2 (Med Risk)
satisfactory
duplicate-519

Awards

118.8832 USDC - $118.88

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L279

Vulnerability details

Impact

Recreated mini pools can be canceled before MinipoolCancelMoratoriumSeconds

Proof of Concept

The protocol disallows cancelation of the mini pools within the MinipoolCancelMoratoriumSeconds=5 days. But the actual implementation uses staking.getRewardsStartTime to check this restriction and getRewardsStartTime does not always reflect the pool creation time. For example, recreated mini pools still have the previously set getRewardsStartTime and as a result, they can be canceled 5 days since the re-creation.

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);
}

Tools Used

Manual Review

Add a new state variable to store the pool creation time and use that to check the cancelation moratorium.

#0 - c4-judge

2023-01-09T12:40:13Z

GalloDaSballo marked the issue as duplicate of #519

#1 - c4-judge

2023-02-08T10:03:56Z

GalloDaSballo marked the issue as satisfactory

Findings Information

🌟 Selected for report: 0xbepresent

Also found by: 0xbepresent, cccz, cozzetti, datapunk, hansfriese

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
duplicate-471

Awards

492.1393 USDC - $492.14

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L46 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L56

Vulnerability details

Impact

Errored mini pools still can get rewards

Proof of Concept

The Rialto calls calculateAndDistributeRewards() to distribute the rewards to the node operators. It is likely that the Rialto checks if a node op is eligible for rewards by calling isEligible().

According to the isEligible(), the staker is eligible for rewards if rewardsStartTime!=0 and block.timestamp - rewardsStartTime>=RewardsEligibilityMinSeconds (14 days).

As I mentioned in the other issue, rewardsStartTime is set to the current block timestamp on creation of the pools (if not set yet).

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());
}

And looking at the function calculateAndDistributeRewards(), the rewards are decided by the EffectiveGGPStaked and it is based on the AVAXAssignedHighWater of the staker in that cycle.

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);
  }
}

The problem is that both rewardsStartTime and AVAXAssignedHighWater are not reset when the pool is transitioned to Error state. So the errored mini pools still can get the rewards and this means the other honest node operators will get less rewards. Below is a test to show that the errored pool still gets the reward.

function testAudit() public {
  skip(dao.getRewardsCycleSeconds());
  rewardsPool.startRewardsCycle();
  // Node Op 1
  address nodeOp1 = getActorWithTokens("nodeOp1", MAX_AMT, MAX_AMT);
  vm.startPrank(nodeOp1);
  ggAVAX.depositAVAX{ value: 2000 ether }();
  ggp.approve(address(staking), MAX_AMT);
  staking.stakeGGP(100 ether);
  MinipoolManager.Minipool memory mp1 = createMinipool(
    1000 ether,
    1000 ether,
    2 weeks
  );
  rialto.processMinipoolStart(mp1.nodeID);
  vm.stopPrank();

  // Node Op 2
  address nodeOp2 = getActorWithTokens("nodeOp2", MAX_AMT, MAX_AMT);
  vm.startPrank(nodeOp2);
  ggAVAX.depositAVAX{ value: 2000 ether }();
  ggp.approve(address(staking), MAX_AMT);
  staking.stakeGGP(100 ether);
  MinipoolManager.Minipool memory mp2 = createMinipool(
    1000 ether,
    1000 ether,
    2 weeks
  );
  rialto.processMinipoolStart(mp2.nodeID);
  vm.stopPrank();
  emit log_named_uint(
    "effectiveGGPStaked before error",
    staking.getEffectiveGGPStaked(nodeOp1)
  );

  // Error occured in the first node
  vm.startPrank(address(rialto));
  bytes32 errorCode = "INVALID_NODE";
  minipoolMgr.recordStakingError{ value: 2000 ether }(mp1.nodeID, errorCode);
  vm.stopPrank();

  // getEffectiveGGPStaked(nodeOp1) still returns valid value
  emit log_named_uint(
    "effectiveGGPStaked after error",
    staking.getEffectiveGGPStaked(nodeOp1)
  );

  // Claim
  vm.startPrank(address(rialto));
  nopClaim.calculateAndDistributeRewards(nodeOp1, 200 ether);
  assertEq(
    staking.getGGPRewards(nodeOp1),
    (nopClaim.getRewardsCycleTotal()) / 2
  );
  vm.stopPrank();
}

Tools Used

Manual Review, Foundry

Consider updating the AVAXAssignedHighWater only when the validation succeeds with rewards.

#0 - c4-judge

2023-01-10T19:36:02Z

GalloDaSballo marked the issue as duplicate of #471

#1 - GalloDaSballo

2023-01-10T19:36:13Z

Pretty much as good as the primary

#2 - c4-judge

2023-02-03T10:10:20Z

GalloDaSballo changed the severity to 2 (Med Risk)

#3 - c4-judge

2023-02-08T20:10:54Z

GalloDaSballo marked the issue as satisfactory

Findings Information

🌟 Selected for report: ladboy233

Also found by: hansfriese

Labels

bug
2 (Med Risk)
downgraded by judge
partial-25
investigate
duplicate-122

Awards

421.9301 USDC - $421.93

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L557

Vulnerability details

Impact

Wrong amount of GGP tokens are slashed from the node operators

Proof of Concept

When the node didn't operate well, the protocol punishes the node operator by slashing the GGP collateral. The slash amount is calculated based on the expected reward amount that is calculated as below.

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;//@audit rate is not linear
}

Looking at the function getExpectedAVAXRewardsAmt(), the expected rate is being used linearly. But that rate is specified as an annual rate in the ProtocolDAO and we should calculate the daily reward rate and use exponential terms instead of linear calculation.

In the default scenario, ProtocolDAO.ExpectedAVAXRewardsRate is set to 10% annual rate for each AVAX staked.

With the current implementation, the reward rate for a day is 10% / 365=0.02739726027397260273972602739726% / day. But the actual daily reward rate is 1.1^(1/365)=1.0002611578760678121616866817054.

For example, for (1000 AVAX, 100 days) :

The current implementation: 1000 AVAX * 10% * 100 days / 365 days = 27.39726027397260273972602739726 AVAX The correct amount: 1000 AVAX * (1.0002611578760678121616866817054^100 - 1) = 26.456293126865688807002170685795 AVAX

I found test cases written in MinipoolManager.t.sol and those are wrong as well. 10% increase a year is not equal to 5% increase for 6 months.

    // `MinipoolManager.t.sol
	function testExpectedRewards() public {
		uint256 amt = minipoolMgr.getExpectedAVAXRewardsAmt(365 days, 1_000 ether);
		assertEq(amt, 100 ether);
		amt = minipoolMgr.getExpectedAVAXRewardsAmt((365 days / 2), 1_000 ether);
		assertEq(amt, 50 ether);
        ...
    }

Tools Used

Manual Review

Calculate the daily reward rate or specify the daily reward rate in the ProtocolDAO contract level and use that.

#0 - GalloDaSballo

2023-01-10T17:54:08Z

Keeping Unique for now

#1 - emersoncloud

2023-01-12T20:50:21Z

We are going to investigate and verify the math before we agree to a severity

#2 - GalloDaSballo

2023-01-31T15:32:53Z

I believe that we're missing:

  • Discrete AVAX quantities that are necessary for computing yield (you cannot spin any pool it's a discrete size)
  • Compound would be based on that
  • Difference is most likely marginal because slashing should be on a Period by Period (14 days I believe), meaning that a 1 year slashing is not realistic (another issue reported the fact that slashing is based on duration vs the Period)

Rewards will be sent at the end of the period per the docs <img width="902" alt="Screenshot 2023-01-31 at 16 29 58" src="https://user-images.githubusercontent.com/13383782/215803665-83ab6edd-4ab1-434c-9936-7c2afb694758.png">

That means that interest is not compounded daily, but at Period at best.

If we agree that slashing should happen on each period, then we should be setting: getExpectedAVAXRewardsRate to be the 14 days APR and duration should be fixed to 14 days

I'm thinking the finding can be awarded, but I think it's contents are not precise

#3 - GalloDaSballo

2023-02-01T20:44:11Z

I believe I would have rated Medium Severity if the contents of the report where more accurate.

Because I believe the math to be incorrect, but the finding to have validity, am downgrading to QA Low Severity

#4 - GalloDaSballo

2023-02-01T20:44:14Z

L

#5 - c4-judge

2023-02-01T20:44:22Z

#6 - GalloDaSballo

2023-02-01T20:45:03Z

Btw my math is also for sure wrong, but compounding period has got to be 14 days per the avax docs / staking rewards cadence

#7 - captainmangoC4

2023-02-02T20:33:54Z

Changing severity to medium per @GalloDaSballo 's request

#8 - c4-judge

2023-02-02T21:12:51Z

GalloDaSballo marked the issue as duplicate of #122

#9 - c4-judge

2023-02-02T21:12:59Z

GalloDaSballo marked the issue as partial-50

#10 - c4-judge

2023-02-02T21:13:29Z

GalloDaSballo marked the issue as partial-25

#11 - GalloDaSballo

2023-02-02T21:14:00Z

The report simply states that the math looks off, but their math is off as well, am awarding 25 for the pointer to a more complex issue

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter