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: 20/111
Findings: 8
Award: $1,504.11
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: hansfriese
Also found by: Allarious, HollaDieWaldfee, betweenETHlines, chaduke, unforgiven, wagmi
949.1258 USDC - $949.13
AVAXAssignedHighWater
variable is the highest amount of AVAX a Node Operator was assigned during a given rewards period. A minipool can end in the middle of a reward cycle, this variable is used to make sure stakers still get rewarded, even though at the time rewards are distributed they might not have a minipool.
Basically it should be the highest amount of AVAX assigned, when it found that current assigned amount is larger than AVAXAssignedHighWater
, AVAXAssignedHighWater
should be set to current assigned amount. However, the current logic is wrong, it increased the amount instead of just set it to current amount.
if (staking.getAVAXAssignedHighWater(owner) < staking.getAVAXAssigned(owner)) { staking.increaseAVAXAssignedHighWater(owner, avaxLiquidStakerAmt); }
So if stakers has more than 1 minipool, they could receive more reward than they should.
Consider the scenario
pool1.AVAXAssigned = 1000 AVAX
and pool2.AVAXAssigned = 2000 AVAX
.recordStakingStart()
for pool1
, AVAXAssignedHighWater = 0 < 1000 AVAX
so it is increased by 1000 AVAX
. So now AVAXAssignedHighWater = 1000 AVAX
.pool2
, Alice is assigned 2000 AVAX
more, so staking.getAVAXAssigned(Alice) = 3000
. And again 1000 < 3000
, so AVAXAssignedHighWater
is increased by 3000 AVAX
.AVAXAssignedHighWater = 4000
AVAX while it should only at most 1000 + 2000 = 3000 AVAX
.Manual Review
Set AVAXAssignedHighWater
to current amount instead of increasing it.
#0 - c4-judge
2023-01-09T20:36:31Z
GalloDaSballo marked the issue as duplicate of #566
#1 - c4-judge
2023-02-08T09:48:25Z
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
9.9345 USDC - $9.93
Each minipool has a state and a set of states that it can move to. In the codebase, state ERROR
and WITHDRAWABLE
is allowed to move to state PRELAUNCH
.
function requireValidStateTransition(int256 minipoolIndex, MinipoolStatus to) private view { ... } else if (currentStatus == MinipoolStatus.Withdrawable || currentStatus == MinipoolStatus.Error) { isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Prelaunch); } ... }
As we can see, when minipool in state ERROR
or WITHDRAWABLE
, owner of the minipool can call withdrawMinipoolFunds()
to claim all AVAX funds from Vault. However, since it is allowed to move to state PRELAUNCH
, attacker can call createMinipool()
with nodeId
of existing minipool which is in state ERROR
for example.
In createMinipool()
, if minipool exists, it will reset all param of minipool and set new owner
function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { ... if (minipoolIndex != -1) { requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex); // Also reset initialStartTime as we are starting a whole new validation setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), 0); } ... setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); ... }
The result is the owner of that minipool will be overriden to be attacker address and victim funds (unclaimed funds) is lost.
Consider the scenario:
avaxAssignmentRequest = 2000 AVAX
.recordStakingError()
to return Alice and liquid stakers' AVAX.withdrawMinipoolFunds()
, Bob (attacker) calls createMinipool()
with nodeId
is Alice's minipool nodeId.withdrawMinipoolFunds()
will fail.Manual Review
Do not allow moving to state PRELAUNCH
from state that fund is not claimed.
#0 - 0xminty
2023-01-03T23:43:09Z
dupe of #805
#1 - c4-judge
2023-01-09T12:37:03Z
GalloDaSballo marked the issue as duplicate of #213
#2 - c4-judge
2023-02-03T12:33:01Z
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:45Z
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-08T20:23:56Z
GalloDaSballo marked the issue as satisfactory
#7 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#8 - Simon-Busch
2023-02-09T12:48:12Z
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
28.5997 USDC - $28.60
Judge has assessed an item in Issue #653 as 2 risk. The relevant finding follows:
Detail Function finishFailedMinipoolByMultisig() did not transfer any funds or doing any data change, only updating state of minipool to Finished. In the comment, it said that it used to finish error pool. However, if minipool is at state Error, it still has AVAX stored in Vault. If it just update state to Finished, this amount of AVAX is locked and cannot return to owner.
Even though this issue is loss of funds but since it can only happen by Rialto calling so I put it in Low.
Recommendation Allowing Rialto to withdraw funds and maybe return to owner later.
#0 - c4-judge
2023-02-03T16:45:03Z
GalloDaSballo marked the issue as duplicate of #723
#1 - c4-judge
2023-02-03T16:45:11Z
GalloDaSballo marked the issue as partial-50
#2 - GalloDaSballo
2023-02-03T16:45:27Z
Ultimately get's the issue right, but missing a little bit of context and info vs the main
🌟 Selected for report: wagmi
Also found by: HollaDieWaldfee, __141345__, chaduke, hansfriese, peritoflores, rvierdiiev, sces60107, slowmoses, supernova
188.8922 USDC - $188.89
When doing inflation, function getInflationAmt()
calculated number of intervals elapsed by dividing the duration with interval length.
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(); }
As we can noticed that, this calculation is rounding down, it means if block.timestamp - startTime = 1.99 intervals
, it only account for 1 interval
.
However, when updating start time after inflating, it still update to current timestamp while it should only increased by intervalLength * intervalsElapsed
instead.
addUint(keccak256("RewardsPool.InflationIntervalStartTime"), inflationIntervalElapsedSeconds); // @audit should only update to oldStartTime + inverval * numInterval setUint(keccak256("RewardsPool.RewardsCycleTotalAmt"), newTokens);
Since default value of inflation interval = 1 days and reward cycle length = 14 days, so the impact is reduced. However, these configs can be changed in the future.
Consider the scenario:
InflationIntervalStartTime = 100
. InflationIntervalSeconds = 50
.timestamp = 199
, function getInflationAmt()
will calculateinflationIntervalsElapsed = (199 - 100) / 50 = 1 // Compute inflation for total inflation intervals elapsed for (uint256 i = 0; i < inflationIntervalsElapsed; i++) { newTotalSupply = newTotalSupply.mulWadDown(inflationRate); } // @audit only loop once.
inflate()
function, InflationIntervalStartTime
is still updated to current timestamp, so InflationIntervalStartTime = 199
.InflationIntervalStartTime = 199, inflated count = 1 InflationIntervalStartTime = 298, inflated count = 2 InflationIntervalStartTime = 397, inflated count = 3 InflationIntervalStartTime = 496, inflated count = 4 InflationIntervalStartTime = 595, inflated count = 5
While at timestamp = 595
, inflated times should be
(595 - 100) / 50 = 9
instead.
Manual Review
Consider only increasing InflationIntervalStartTime
by the amount of intervals time interval length.
#0 - peritoflores
2023-01-04T12:43:53Z
Dupe #811
#1 - GalloDaSballo
2023-01-09T20:42:08Z
Primary for now due to better explanation
#2 - c4-judge
2023-01-09T20:42:11Z
GalloDaSballo marked the issue as primary issue
#3 - c4-sponsor
2023-01-11T20:32:20Z
emersoncloud marked the issue as disagree with severity
#4 - emersoncloud
2023-01-11T20:34:04Z
I agree with this issue but assets can't be stolen, lost or compromised directly. Medium severity is more appropriate https://docs.code4rena.com/awarding/judging-criteria#estimating-risk
#5 - GalloDaSballo
2023-01-29T18:56:21Z
I have considered a Higher Severity, due to logical flaws.
However, I believe that the finding
For those reasons, I believe Medium Severity to be the most appropriate
#6 - c4-judge
2023-01-29T18:57:00Z
GalloDaSballo changed the severity to 2 (Med Risk)
#7 - emersoncloud
2023-02-07T20:48:57Z
Acknowledged, not fixing in this first version of the protocol.
We can and will have rialto call startRewardsCycle if needed, and think it's unlikely to become delayed.
#8 - c4-judge
2023-02-08T08:30:49Z
GalloDaSballo marked the issue as selected for report
🌟 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
From Discord channel
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.
So basically, Rialto calls recordStakingEnd()
then recreateMinipool()
to compound the rewards for owners. After calling recordStakingEnd()
, minipool is at Withdrawable
state.
Because of that, attacker (owner of minipool) can front-run recreateMinipool()
, calling withdrawMinipoolFunds()
, withdraw the funds. Interestingly, function withdrawMinipoolFunds()
did not reset any data of minipool (avaxNodeOpAmt
and avaxNodeOpRewardAmt
are not reset) and state is moved to Finished
.
However, Finished -> Prelaunch
is possible so recreateMinipool()
will not fail and still create the minipool and increase AVAX stake of owner.
Consider the scenario
recordStakingEnd()
then call recreateMinipool()
to compound rewards for Alice.recreateMinipool()
) and call withdrawMinipoolFunds()
. Alice get 1000 AVAX back.Manual Review
Consider resetting data when withdrawing minipool funds.
#0 - c4-judge
2023-01-09T12:56:25Z
GalloDaSballo marked the issue as duplicate of #484
#1 - c4-judge
2023-02-03T12:41:12Z
GalloDaSballo marked the issue as duplicate of #569
#2 - c4-judge
2023-02-08T08:27:15Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-08T20:15:30Z
GalloDaSballo marked the issue as satisfactory
#4 - c4-judge
2023-02-09T08:45:54Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#5 - Simon-Busch
2023-02-09T12:33:11Z
Changed 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
For each owner, there is a wait period requirement before they can cancel their minipool after creation.
if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { revert CancellationTooEarly(); // @audit Minipool can be cancelled immediately after creation if owner has more than 1 pool }
However, as we can see, it used rewards start time of owner to check this wait period requirement. If owner has more than 1 pool, the second, third,... pools can be cancelled immediately after creation because duration from first pool creation to current timestamp is enough to pass minipoolCancelMoratoriumSeconds
.
Consider the scenario
t1 = 100
. Rewards start time is recorded at that momemnt rewardsStartTime = 100
.t2 = 200
. Since 200 - rewardsStartTime = 100
already so Alice can cancelled minipool B immediately after its creation.Manual Review
Consider using another field to store creation timestamp of minipool.
#0 - c4-judge
2023-01-10T08:15:52Z
GalloDaSballo marked the issue as duplicate of #519
#1 - c4-judge
2023-02-08T10:03:54Z
GalloDaSballo marked the issue as satisfactory
118.8832 USDC - $118.88
GGP staked is used to compensate liquid stakers in case minipool did not receive AVAX reward (low uptime). However, price of GGP at staking time and at slashing time might be different, for example, price might get lower.
function slash(int256 index) private { address nodeID = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".nodeID"))); address owner = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".owner"))); uint256 duration = getUint(keccak256(abi.encodePacked("minipool.item", index, ".duration"))); uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt); setUint(keccak256(abi.encodePacked("minipool.item", index, ".ggpSlashAmt")), slashGGPAmt); emit GGPSlashed(nodeID, slashGGPAmt); Staking staking = Staking(getContractAddress("Staking")); staking.slashGGP(owner, slashGGPAmt); // @audit cannot slash if GGP staked is less than slashGGPAmt }
In that case, slashGGPAmt
might increase when it get slashed. And instead of slash as much as possible, for example, slash all available amount of owner, it fail and revert the transaction.
Minipool cannot be slashed, it means liquid stakers did not get funds back and node operator AVAX is locked too.
Consider the scenario:
1000 AVAX - 1000 AVAX Assigned
. At this moment, 1 AVAX = 1 GGP
and Alice stake 100 GGP
.100 GGP
should be slashed to compensate liquid staker reward from 1000 AVAX
.1 AVAX = 2 GGP
, so the slashGGPAmt
is calculated to be 200 GGP
. Instead of get as much as possible to compenstate (100 GGP), TX fail when trying to slash 200 GGP
.function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); decreaseGGPStake(stakerAddr, ggpAmt); vault.transferToken("ProtocolDAO", ggp, ggpAmt); }
Manual Review
Consider slashing as much as possible in case there is not enough GGP in owner balance.
#0 - c4-judge
2023-01-09T09:46:52Z
GalloDaSballo marked the issue as duplicate of #136
#1 - c4-judge
2023-02-03T19:36:22Z
GalloDaSballo marked the issue as not a duplicate
#2 - c4-judge
2023-02-03T19:56:06Z
GalloDaSballo marked the issue as duplicate of #494
#3 - c4-judge
2023-02-03T19:57:49Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-08T20:16:50Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: HollaDieWaldfee
Also found by: Allarious, Aymen0909, Saintcode_, SmartSek, adriro, bin2chen, cccz, kaliberpoziomka8552, minhtrng, rvierdiiev, sces60107, sk8erboy, wagmi
68.0946 USDC - $68.09
Minipool count of each staker address is used ClaimNodeOp to reset rewards start time in case there is no minipool running. However, the accouting logic in MinipoolManager
forgot to decrease minipoolCount
in recordStakingError()
function.
function recordStakingError(address nodeID, bytes32 errorCode) external payable { ... // Return Liq stakers funds ggAVAX.depositFromStaking{value: avaxLiquidStakerAmt}(avaxLiquidStakerAmt, 0); Staking staking = Staking(getContractAddress("Staking")); staking.decreaseAVAXAssigned(owner, avaxLiquidStakerAmt); // @audit forget to decrease minipool count ? ... }
As the result, even when there is no minipool, rewards start time of staker is not reset, staker will get free reward.
// check if their rewards time should be reset uint256 minipoolCount = staking.getMinipoolCount(stakerAddr); if (minipoolCount == 0) { // @audit wrong minipool count, reward time is not reset staking.setRewardsStartTime(stakerAddr, 0); }
Consider the scenario:
createMinipool()
function, minipoolCount
is increased to 1
.recordStakingError()
is called, minipoolCount
still has value of 1
.createMinipool()
again with the same nodeId
, it still call staking.increaseMinipoolCount(msg.sender);
normally, so as the result minipoolCount = 2
while Alice only has 1 minipool actuallyminipoolCount = 1
and rewards start time is not reset.Manual Review
Consider decreasing minipoolCount
in case minipool has error.
#0 - 0xminty
2023-01-04T00:42:00Z
dupe of #235
#1 - c4-judge
2023-01-09T09:42:18Z
GalloDaSballo marked the issue as duplicate of #235
#2 - c4-judge
2023-02-08T19:38:34Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-02-08T19:43:10Z
GalloDaSballo changed the severity to 2 (Med Risk)