Platform: Code4rena
Start Date: 06/01/2023
Pot Size: $210,500 USDC
Total HM: 27
Participants: 73
Period: 14 days
Judge: 0xean
Total Solo HM: 18
Id: 203
League: ETH
Rank: 1/73
Findings: 8
Award: $26,021.41
🌟 Selected for report: 5
🚀 Solo Findings: 4
🌟 Selected for report: 0xA5DF
Also found by: HollaDieWaldfee
5097.886 USDC - $5,097.89
This report deals with how the recollateralization algorithm underestimates the number of baskets it can hold (by calculating unnecessary costs for collateral that does not need to be traded).
When this number of baskets it thinks it can hold is reached - which is less than what it can actually hold - it compromises its baskets (i.e. reduces the number of baskets needed for full collateralization).
This causes RToken holders to lose value as the excess assets are not used to only appreciate RToken value but is also processed in part as rsr
revenue.
In order to understand this issue it is necessary to understand the recollateralization algorithm.
Here is how recollateralization works and how the algorithm under-estimates the baskets it can acquire:
RecollateralizationLib.prepareRecollateralizationTrade
is called to determine the next trade (https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/mixins/RecollateralizationLib.sol#L62-L113)
In this function, the basket range is calculated with the basketRange
function (https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/mixins/RecollateralizationLib.sol#L88)
The recollateralization algorithm tries to recollateralize the RToken up to the bottom of the basket range (https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/mixins/RecollateralizationLib.sol#L380). Therefore it is important that this bottom of the range is correct. As we will see it is underestimated.
The bottom of the range is calculated as assetsLow.div(basketPriceHigh)
(https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/mixins/RecollateralizationLib.sol#L201)
assetsLow
is calculated by the totalAssetValue
function which calculates the sum of all collateral valued with their low
prices (https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/mixins/RecollateralizationLib.sol#L264).
basketPriceHigh
is calculated by the BasketHandler._price
function which values all collaterals in a basket with their high
prices (https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/BasketHandler.sol#L337-L347)
The issue with this becomes clear when thinking about an example. Assume the BackingManager
holds 10 WBTC and it holds so many baskets that 7 WBTC are used up. These 7 WBTC can be considered to be "good" and need not be traded for other collateral.
The question should now be how to divide the ADDITIONAL 3 WBTC across collateral to increase the number of baskets held. However the recollateralization algorithm calculates as though it would need to sell those 7 WBTC at the low price and buy them again at the high price. E.g. sell them at $19000 and buy again at $20000.
Thereby the recollateralization algorithm ends up with a bottom basket range that underestimates how many baskets can actually be aquired.
VSCode
It is clear that the recollateralization algorithm must be changed to not devalue collateral that will not be traded.
There are two possibilities for this:
assetsLow
can be calculated with two different prices for each asset. The part that will not be traded anymore is valued with the high price, the part that might be traded is valued with the low price. Thereby when dividing assetsLow
by basketPriceHigh
, the collateral that is held and will not be traded is not devalued.
Perform the recollateralization calculations on the part of the assets that might be traded only. Thereby leaving out the "good" collateral entirely. This would require more changes to the code but could be a cleaner solution.
I encourage the sponsor to investigate the above two potential solutions. The recollateralization algorithm is the most math-heavy part of the protocol. So any changes must be carefully considered and tested.
#0 - 0xean
2023-01-24T19:11:06Z
leaving open for sponsor comment.
#1 - c4-judge
2023-01-24T19:13:12Z
0xean marked the issue as satisfactory
#2 - c4-sponsor
2023-01-25T23:36:46Z
tbrent marked the issue as sponsor acknowledged
#3 - tbrent
2023-01-25T23:37:07Z
Duplicate with #235
#4 - c4-judge
2023-01-31T15:11:13Z
0xean marked the issue as duplicate of #235
#5 - c4-judge
2023-01-31T16:31:01Z
0xean changed the severity to 3 (High Risk)
🌟 Selected for report: HollaDieWaldfee
4418.1679 USDC - $4,418.17
The BackingManager.handoutExcessAssets
function sends all rsr
that the BackingManager
holds to the rsrTrader
(https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/BackingManager.sol#L173-L179).
The purpose of this is that rsr
which can be held by the BackingManager
due to seizure from the StRSR
contract is sent back entirely to the StRSR
contract and not - as would happen later in the function (https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/BackingManager.sol#L221-L242) - shared across rsrTrader
and rTokenTrader
.
The rsrTrader
then sends the rsr
to the Distributor
(https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/RevenueTrader.sol#L59-L65).
So far so good. However the Distributor
does not necessarily send all of the rsr
to the StRSR
contract. Instead it distributes the rsr
according to its distribution table. I.e. there can be multiple destinations each receiving a share of the rsr
(https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/Distributor.sol#L108-L136).
In economic terms, rsr
that is thereby not sent to StRSR
but to other destinations, is a transfer of funds from stakers to these destinations, i.e. a loss to stakers.
Stakers should only pay for recollateralization of the RToken
, not however send revenue to rsr
revenue destinations.
Assume the following situation:
A seizure of rsr
from the StRSR
contract occurred because the RToken
was under-collateralized.
A trade occurred which restored collateralization. However not all rsr
was sold by the trade and was returned to the BackingManager
.
Now BackingManager.manageTokens
is called which due to the full collateralization calls BackingManager.handoutExcessAssets
(https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/BackingManager.sol#L118).
This sends rsr
to the rsrTrader
(https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/BackingManager.sol#L173-L179).
Then the rsr
is sent to the Distributor
(https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/RevenueTrader.sol#L59-L65).
There it is distributed across all rsr
destinations (https://github.com/reserve-protocol/protocol/blob/b30ab2068dddf111744b8feed0dd94925e10d947/contracts/p1/Distributor.sol#L108-L136).
VSCode
rsr
should be sent from the BackingManager
directly to StRSR
without the need to go through rsrTrader
and Distributor
. Thereby it won't be sent to other rsr
revenue destinations.
Fix:
diff --git a/contracts/p1/BackingManager.sol b/contracts/p1/BackingManager.sol index 431e0796..eb506004 100644 --- a/contracts/p1/BackingManager.sol +++ b/contracts/p1/BackingManager.sol @@ -173,7 +173,7 @@ contract BackingManagerP1 is TradingP1, IBackingManager { if (rsr.balanceOf(address(this)) > 0) { // For CEI, this is an interaction "within our system" even though RSR is already live IERC20Upgradeable(address(rsr)).safeTransfer( - address(rsrTrader), + address(stRSR), rsr.balanceOf(address(this)) ); }
There is a caveat to this however:
It is possible for rsr
to be a reward token for a collateral of the RToken
.
Neither the current implementation nor the proposed fix addresses this and instead sends the rewards to StRSR
.
In principal, rsr
that was rewarded should have a share that goes to the rTokenTrader
as well as include all rsr
revenue destinations.
However there is no easy way to differentiate where the rsr
came from.
Therefore I think it is reasonable to send all rsr
to StRSR
and make it clear to developers and users that rsr
rewards cannot be paid out to rToken
holders.
#0 - c4-judge
2023-01-24T14:26:45Z
0xean marked the issue as satisfactory
#1 - tbrent
2023-01-26T00:31:59Z
Yep, this one is a great find.
#2 - c4-sponsor
2023-01-26T00:32:06Z
tbrent marked the issue as sponsor confirmed
#3 - tbrent
2023-02-07T23:16:54Z
🌟 Selected for report: immeas
Also found by: HollaDieWaldfee, JTJabba, hihen, rvierdiiev, unforgiven, wait
258.0215 USDC - $258.02
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L186-L340 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L344-L370 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L406-L418 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L72
rToken can be issued via the RToken.issue
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L186-L340).
There is a limit to how many rToken can be minted within one block (a percentage of the rToken supply).
If RToken.issue
is called but the rToken cannot be minted immediately, the issuance is added to a queue.
The time when the issuance can be vested (i.e. when the rToken can be actually minted) is determined by the RToken.whenFinished
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L344-L370) which is called internally by RToken.issue
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L243).
If there is an issuance in the queue it can be cancelled by the recipient of the issuance by calling RToken.cancel
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L406-L418).
All the tokens from the basket are then refunded.
So when a user calls RToken.issue
and the issuance is queued and immediately after that he calls RToken.cancel
, there is no cost to the user except the Gas.
The issue is that by performing this issue -> cancel
roundtrip, the allVestAt
variable (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L72) is increased (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L369). This means that other users that now call RToken.issue
need now to wait longer to vest their rToken.
So an attacker can repeatedly call issue
and cancel
to essentially increase the allVestAt
to a very large block number (maybe years in the future).
The amount of blocks by which allVestAt
is increased depends on the capital the attacker uses to perform one issue -> cancel
roundtrip (this capital is not lost, just used for some amount of time) and the number of roundtrips (which costs a little bit of Gas).
Assume the attacker uses enough capital to increase allVestAt
by 5 blocks per roundtrip (i.e. approximately 60 seconds) and one roundtrip costs $1 in Gas.
It then costs $525,600 to increase allVestAt
by 1 year:
1 year is 31,536,000 seconds 31,536,000 seconds / 12 seconds per block = 2,628,000 blocks 2,628,000 blocks / 5 blocks per roundtrip = 525,600 roundtrips 525,600 roundtrips * $1 per roundtrip = $525,600
This is definitely within reach of a determined attacker. Also Gas costs might decrease in the future which makes this attack even more dangerous.
The vision of Reserve is to provide a protocol to create currencies that are used worldwide with a TVL of possibly trillions of dollars.
The attack described above requires little capital in comparison to the TVL and can be very effective in denying the protocol to operate.
Thereby this attack, although it does not pose a risk to the users' assets, makes it impossible for the protocol to satisfy its ambitions. I therefore rate this issue to be of "High" severity.
allVestAt
is a block number in the future. This means that by calling RToken.issue
, the rToken
are not issued immediately but added to the issuance queue instead. So the issuance can be canceled.allVestAt
is not a block number in the future, the attacker must issue some amount of rToken
immediately. This amount can the be redeemed again. So there is just an added need for capital to perform the attack.RToken.issue
to increase allVestAt
and immediately after that he calls RToken.cancel
. All collateral tokens are returned to him again.allVestAt
is as large as he wants.rToken
that are issued from now on can only be vested when block.timestamp
has reached allVestAt
.VSCode
There is no easy fix for this. Any change to the issuance mechanism changes the economic incentives of the protocol.
A possible solution can be to introduce a cancellation fee such that the attack becomes too expensive or to "recharge" the issuance capacity when an issuance is cancelled.
However the sponsor should explore possible mechanisms and make sure they don't introduce any bad economic incentives.
#0 - c4-judge
2023-01-22T15:07:22Z
0xean marked the issue as satisfactory
#1 - c4-judge
2023-01-22T15:08:37Z
0xean marked the issue as duplicate of #364
#2 - c4-judge
2023-01-22T15:08:53Z
0xean changed the severity to 2 (Med Risk)
🌟 Selected for report: HollaDieWaldfee
4418.1679 USDC - $4,418.17
The RecollateralizationLib.basketRange
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/mixins/RecollateralizationLib.sol#L152-L202) internally calls the RecollateralizationLib.totalAssetValue
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/mixins/RecollateralizationLib.sol#L226-L281).
I will show in this report that the RecollateralizationLib.totalAssetValue
function returns a value for assetsLow
that is too low.
This in turn causes the range.bottom
value (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/mixins/RecollateralizationLib.sol#L201) that the RecollateralizationLib.basketRange
function returns to be too low.
Before showing why the assetsLow
value is underestimated however I will explain the impact of the range.bottom
variable being too low.
There are two places where this value is used:
RecollateralizationLib.prepareRecollateralizationTrade
functionThis function passes the range
to the RecollateralizationLib.nextTradePair
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/mixins/RecollateralizationLib.sol#L88-L91)
Since range.bottom
is too low, the needed
amount is too low (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/mixins/RecollateralizationLib.sol#L380).
This causes the if
statement to not be executed in some cases when it otherwise would be executed (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/mixins/RecollateralizationLib.sol#L381-L396).
And the amtShort
is smaller than it should be (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/mixins/RecollateralizationLib.sol#L391).
In the end this causes recollateralization trades to not buy as much assets as they could buy. This is because the amount of assets is underestimated so the protocol can actually hold more baskets than it thinks it can.
Therefore underestimating assetsLow
causes a direct loss to RToken holders because the protocol will not recollateralize the RToken to the level that it can and should.
RTokenAsset
A RTokenAsset
uses the RecollateralizationLib.basketRange
function to calculate its value:
The RTokenAsset
therefore underestimates its low
and lotLow
prices:
This then can lead to issues in any places where the prices of RTokenAsset
s are used.
Here is the affected line:
potentialDustLoss = potentialDustLoss.plus(rules.minTradeVolume);
This line is executed for every asset in the AssetRegistry
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/mixins/RecollateralizationLib.sol#L242).
So for every asset in the AssetRegistry
a potential dust loss of minTradeVolume
is added.
The following scenario shows why this is wrong:
assume minTradeVolume = $50 assume further the following: asset1 with low value $1 asset2 with low value $1 asset3 with low value $1 asset4 with low value $200 Currently potentialDustLoss will be 4*minTradeVolume = $200. So assetsLow = $203 - $200 = $3. Dust loss should not be calculated with $50 for the first 3 assets. Dust loss for an asset should be capped at its low value. So dust loss alltogether should be $1 + $1 + $1 + $50 = $53. So assetsLow should be $1+$1+$1+$200 - $53 = $150.
VSCode
I suggest that an asset can only incur as much dust loss as its balance is.
If the protocol only holds $5
of asset A then this should not cause a dust loss of say $10
.
The fix first saves the assetLow
value which should be saved to memory because it is now needed two times then it caps the dust loss of an asset at its low value:
diff --git a/contracts/p1/mixins/RecollateralizationLib.sol b/contracts/p1/mixins/RecollateralizationLib.sol index 648d1813..b5b86cac 100644 --- a/contracts/p1/mixins/RecollateralizationLib.sol +++ b/contracts/p1/mixins/RecollateralizationLib.sol @@ -261,7 +261,8 @@ library RecollateralizationLibP1 { // Intentionally include value of IFFY/DISABLED collateral when low is nonzero // {UoA} = {UoA} + {UoA/tok} * {tok} - assetsLow += low.mul(bal, FLOOR); + uint192 assetLow = low.mul(bal,FLOOR); + assetsLow += assetLow; // += is same as Fix.plus // assetsHigh += high.mul(bal, CEIL), where assetsHigh is [0, FIX_MAX] @@ -272,7 +273,7 @@ library RecollateralizationLibP1 { // += is same as Fix.plus // Accumulate potential losses to dust - potentialDustLoss = potentialDustLoss.plus(rules.minTradeVolume); + potentialDustLoss = potentialDustLoss.plus(fixMin(rules.minTradeVolume, assetLow)); } // Account for all the places dust could get stuck
#0 - 0xean
2023-01-24T13:58:00Z
It would have been beneficial for the warden to use more realistic values for these trades with the full integer values to show how much of an actual impact this has when we are talking about tokens with 6 or more decimals, will leave open for sponsor comment
#1 - c4-judge
2023-01-24T13:58:07Z
0xean marked the issue as satisfactory
#2 - c4-sponsor
2023-01-27T18:11:24Z
pmckelvy1 marked the issue as sponsor disputed
#3 - c4-sponsor
2023-01-27T18:11:28Z
pmckelvy1 requested judge review
#4 - tbrent
2023-01-27T18:12:36Z
The balance of the asset before trading has nothing to do with how much value can potentially be lost when we try to trade into that asset.
#5 - tbrent
2023-01-27T21:46:01Z
On further thought, this is not really a good response. We have access to the UoA from the asset, and we could use that to potentially limit the contribution to potentialDustLoss
.
#6 - c4-sponsor
2023-01-27T21:46:29Z
tbrent marked the issue as sponsor confirmed
🌟 Selected for report: HollaDieWaldfee
4418.1679 USDC - $4,418.17
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L374-L422 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L596-L598 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L496-L530
If a RToken is under-collateralized, the BackingManager
can call the StRSR.seizeRSR
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BackingManager.sol#L141).
This sends some amount of rsr
held by the StRSR
contract to the BackingManager
which can then be traded for other tokens in order to recollateralize the RToken.
There are 3 pools of rsr
in the StRSR
contract that StRSR.seizeRSR
claims rsr
from.
stakeRSR
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L386-L398)draftRSR
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L401-L414)The rsr
taken from the rewards is what is interesting in this report.
The issue is that the StRSR._payoutRewards
function (which is used to pay rsr
rewards to stakers over time) keeps track of the available rewards to distribute in the rsrRewardsAtLastPayout
variable (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L517).
When the StRSR.seizeRSR
function is called (taking away rewards and sending them to the BackingManager
) and after that StRSR._payoutRewards
is called, StRSR._payoutRewards
uses the rsrRewardsAtLastPayout
variable that was set before the seizure (the actual amount of rewards is smaller after the seizure).
Thereby the amount by which StRSR.stakeRSR
is increased (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L513) when rewards are paid out can be greater than the actual rewards that are available.
The fact that the rsrRewardsAtLastPayout
variable is too big after a call to StRSR.seizeRSR
has two consequences when StRSR._payoutRewards
is called:
stakeRSR
is increased by an amount that is larger than it should be (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L513)stakeRate
(which uses division by stakeRSR
when calculated) is smaller than it should be (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L524-L526)Both affected variables can in principle be off by a large amount. In practice this is not likely because the rewards paid out will be small in comparison to stakeRSR
.
Also after a second call to StRSR._payoutRewards
all variables are in sync again and the problem has solved itself. The excess payouts are then accounted for by the StRSR.rsrRewards
function.
So there is a small amount of time for any real issue to occur and there does not always occur an issue when StRSR.seizeRSR
is called.
That being said, the behavior described so far can cause a temporary DOS:
In StRSR._payoutRewards
, stakeRSR
is increased (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L513), then StRSR.rsrRewards
is called which calculates rsr.balanceOf(address(this)) - stakeRSR - draftRSR
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/StRSR.sol#L596-L598).
The falsely paid out amount of rewards can increase StRSR.stakeRSR
so much that this line reverts due to underflow.
This can cause DOS when StRSR.seizeRSR
is called again because it internally calls StRSR.rsrRewards
.
This will solve itself when more rsr
accumulates in the contract due to revenue which makes the balance increase or someone can just send rsr
and thereby increase the balance.
The DOS occurs also in all functions that internally call StRSR._payoutRewards
(StRSR.stake
and StRSR.unstake
):
Overall the impact of this on the average RToken is quite limited but as explained above it can definitely cause issues.
VSCode
When StRSR.seizeRSR
is called, the rsrRewardsAtLastPayout
variable should be set to the rewards that are available after the seizure:
diff --git a/contracts/p1/StRSR.sol b/contracts/p1/StRSR.sol index 8fe1c3e7..4f9ea736 100644 --- a/contracts/p1/StRSR.sol +++ b/contracts/p1/StRSR.sol @@ -419,6 +419,7 @@ abstract contract StRSRP1 is Initializable, ComponentP1, IStRSR, EIP712Upgradeab // Transfer RSR to caller emit ExchangeRateSet(initRate, exchangeRate()); IERC20Upgradeable(address(rsr)).safeTransfer(_msgSender(), seizedRSR); + rsrRewardsAtLastPayout = rsrRewards(); }
#0 - c4-judge
2023-01-24T13:50:16Z
0xean marked the issue as satisfactory
#1 - c4-sponsor
2023-01-27T18:19:51Z
pmckelvy1 marked the issue as sponsor confirmed
#2 - tbrent
2023-02-07T23:22:31Z
Addressed here: https://github.com/reserve-protocol/protocol/pull/584
🌟 Selected for report: HollaDieWaldfee
4418.1679 USDC - $4,418.17
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L439-L514 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L448 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BasketHandler.sol#L183-L192
The Reserve protocol allows redemption of rToken even when the protocol is paused
.
The docs/system-design.md
documentation describes the paused
state as:
all interactions disabled EXCEPT RToken.redeem + RToken.cancel + ERC20 functions + StRSR.stake
Redemption of rToken should only ever be prohibited when the protocol is in the frozen
state.
You can see that the RToken.redeem
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L439-L514) has the notFrozen
modifier so it can be called when the protocol is in the paused
state.
The issue is that this function relies on the BasketHandler.status()
to not be DISABLED
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L448).
The BasketHandler.refreshBasket
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BasketHandler.sol#L183-L192) however, which must be called to get the basket out of the DISABLED
state, cannot be called by any user when the protocol is paused.
When the protocol is paused it can only be called by the governance (OWNER) address.
So in case the basket is DISABLED
and the protocol is paused, it is the governance that must call refreshBasket
to allow redemption of rToken.
This is dangerous because redemption of rToken should not rely on governance to perform any actions such that users can get out of the protocol when there is something wrong with the governance technically or if the governance behaves badly.
The RToken.redeem
function has the notFrozen
modifier so it can be called when the protocol is paused (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L439).
The BasketHandler.refreshBasket
function can only be called by the governance when the protocol is paused:
require( main.hasRole(OWNER, _msgSender()) || (status() == CollateralStatus.DISABLED && !main.pausedOrFrozen()), "basket unrefreshable" );
Therefore the situation exists where rToken redemption should be possible but it is blocked by the BasketHandler.refreshBasket
function.
VSCode
The BasketHandler.refreshBasket
function should be callable by anyone when the status()
is DISABLED
and the protocol is paused
.
So the above require
statement can be changed like this:
diff --git a/contracts/p1/BasketHandler.sol b/contracts/p1/BasketHandler.sol index f74155b1..963e29de 100644 --- a/contracts/p1/BasketHandler.sol +++ b/contracts/p1/BasketHandler.sol @@ -185,7 +185,7 @@ contract BasketHandlerP1 is ComponentP1, IBasketHandler { require( main.hasRole(OWNER, _msgSender()) || - (status() == CollateralStatus.DISABLED && !main.pausedOrFrozen()), + (status() == CollateralStatus.DISABLED && !main.frozen()), "basket unrefreshable" ); _switchBasket();
It was discussed with the sponsor that they might even allow rToken redemption when the basket is DISABLED
.
In other words only disallow it when the protocol is frozen
.
This however needs further consideration by the sponsor as it might negatively affect other aspects of the protocol that are beyond the scope of this report.
#0 - 0xean
2023-01-24T13:44:16Z
I believe this to be a design choice. Will leave open to sponsor review and most likely downgrade to QA
#1 - c4-judge
2023-01-24T13:44:22Z
0xean marked the issue as satisfactory
#2 - c4-sponsor
2023-01-27T18:25:23Z
pmckelvy1 marked the issue as sponsor acknowledged
#3 - 0xean
2023-01-30T23:28:14Z
the warden identified a state that was inconsistent with sponsors expectations since they acknowledged the issue, I believe this should be M as it does affect the availability of the protocol.
🌟 Selected for report: HollaDieWaldfee
Also found by: unforgiven
1988.1755 USDC - $1,988.18
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L439-L514 https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BackingManager.sol#L72-L77
The Reserve protocol allows redemption of rToken even when the protocol is paused
.
The docs/system-design.md
documentation describes the paused
state as:
all interactions disabled EXCEPT RToken.redeem + RToken.cancel + ERC20 functions + StRSR.stake
Redemption of rToken should only ever be prohibited when the protocol is in the frozen
state.
The issue is that the RToken.redeem
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L439-L514) relies on the BackingManager.grantRTokenAllowance
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/BackingManager.sol#L72-L77) to be called before redemption.
Also the only function that relies on BackingManager.grantRTokenAllowance
to be called before is RToken.redeem
.
Therefore BackingManager.grantRTokenAllowance
can be called at any time before a specific ERC20 needs first be transferred from the BackingManager
for the purpose of redemption of rToken.
The issue is that the BackingManager.grantRTokenAllowance
function has the notPausedOrFrozen
modifier. This means it cannot (in contrast to RToken.redeem
) be called when the protocol is paused
.
Therefore if rToken is for the first time redeemed for a specific ERC20 in a paused
protocol state, BackingManager.grantRTokenAllowance
might not have been called before.
This effectively disables redemption of rToken as long as the protocol is paused
and is clearly against the usability / economic considerations to allow redemption in the paused
state.
For simplicity assume there is an rToken backed by a single ERC20 called AToken
BackingManager
.paused
state before any redemptions have occurred. So the BackingManager.grantRTokenAllowance
function might not have been called at this point.paused
which should allow redemption of rToken but it is not possible because the AToken allowance cannot be granted since the BackingManager.grantRTokenAllowance
function cannot be called in the paused
state.Another scenario is when the basket of a RToken is changed to include an ERC20 that was not included in the basket before. If the protocol now goes into the paused
state without BackingManager.grantRTokenAllowance
being called before, redemption is not possible.
VSCode
The BackingManager.grantRTokenAllowance
function should use the notFrozen
modifier instead of the notPausedOrFrozen
modifier such that allowance can be granted in the paused
state:
diff --git a/contracts/p1/BackingManager.sol b/contracts/p1/BackingManager.sol index 431e0796..7dfa29e9 100644 --- a/contracts/p1/BackingManager.sol +++ b/contracts/p1/BackingManager.sol @@ -69,7 +69,7 @@ contract BackingManagerP1 is TradingP1, IBackingManager { // checks: erc20 in assetRegistry // action: set allowance on erc20 for rToken to UINT_MAX // Using two safeApprove calls instead of safeIncreaseAllowance to support USDT - function grantRTokenAllowance(IERC20 erc20) external notPausedOrFrozen { + function grantRTokenAllowance(IERC20 erc20) external notFrozen { require(assetRegistry.isRegistered(erc20), "erc20 unregistered"); // == Interaction == IERC20Upgradeable(address(erc20)).safeApprove(address(main.rToken()), 0);
#0 - 0xean
2023-01-24T14:15:08Z
I think the warden does identify a possible state of the system that could be problematic, albeit highly unlikely to be realized. Leaving open for sponsor review.
#1 - c4-judge
2023-01-24T14:15:18Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-01-24T16:19:08Z
0xean marked the issue as primary issue
#3 - c4-sponsor
2023-01-25T21:52:18Z
pmckelvy1 marked the issue as sponsor confirmed
#4 - tbrent
2023-02-07T23:23:13Z
Addressed here: https://github.com/reserve-protocol/protocol/pull/584
#5 - c4-judge
2023-02-09T15:00:08Z
0xean marked the issue as selected for report
🌟 Selected for report: CodingNameKiki
Also found by: 0xA5DF, 0xAgro, 0xNazgul, 0xSmartContract, Aymen0909, BRONZEDISC, Bnke0x0, Breeje, Cyfrin, GalloDaSballo, HollaDieWaldfee, IceBear, IllIllI, MyFDsYours, RaymondFam, Ruhum, SaharDevep, Sathish9098, Soosh, Udsen, __141345__, brgltd, btk, carlitox477, chaduke, chrisdior4, cryptonue, delfin454000, descharre, hihen, joestakey, ladboy233, lukris02, luxartvinsec, peanuts, pedr02b2, rotcivegaf, shark, tnevler, yongskiws
1004.6363 USDC - $1,004.64
Risk | Title | File | Instances |
---|---|---|---|
L-01 | Check that longFreeze >= shortFreeze | contracts/mixins/Auth.sol | 1 |
L-02 | Check that RToken contract does not receive rToken distribution | contracts/p1/Distributor.sol | 1 |
L-03 | rToken issuances can be spammed | contracts/p1/RToken.sol | 1 |
L-04 | Asset architecture makes dangerous use of delegatecall | - | - |
L-05 | Introduce max value for delayUntilDefault | contracts/plugins/assets/FiatCollateral.sol | 1 |
L-06 | Only disable basket if new asset is not the same as old asset | contracts/p1/AssetRegistry.sol | 1 |
L-07 | Use lotLow price instead of low price to calculate total asset value | contracts/p1/mixins/RecollateralizationLib.sol | 1 |
L-08 | Round with mode FLOOR instead of CEIL in calculation of range.bottom | contracts/p1/mixins/RecollateralizationLib.sol | 1 |
L-09 | Sending sell or buy tokens to GnosisTrade contract such that reportViolation is not called | contracts/plugins/trading/GnosisTrade.sol | 1 |
L-10 | minBuyAmountPerOrder calculation should be based on asset value not token fraction | contracts/plugins/trading/GnosisTrade.sol | 1 |
N-01 | No need to access components via Main | - | 4 |
N-02 | Use Solidity units when possible | contracts/p1/Broker.sol | 1 |
N-03 | Redundant code | contracts/p1/RToken.sol | 1 |
N-04 | Consider adding functionality to restake drafted withdrawal | contracts/p1/StRSR.sol | 1 |
N-05 | Consider implementing asymmetric peg range | contracts/plugins/assets/FiatCollateral.sol | 1 |
N-06 | Consider leaving the basket DISABLED when it has IFFY collateral and allowing redemption when basket is DISABLED | - | - |
N-07 | Use IStRSRVotes instead of IStRSR interface | contracts/p1/Deployer.sol | - |
longFreeze >= shortFreeze
The docs/system-design.md
documentation states that:
The LONG_FREEZER role should be assigned to an address that will highly optimize for no false positives. It is much longer than the short freeze. It exists so that in the case of a zero-day exploit, governance can act before the system unfreezes and resumes functioning.
This makes it clear that the longFreeze
duration must not be shorter than the shortFreeze
duration. Maybe the longFreeze
should even be strictly longer than the shortFreeze
.
If longFreeze
is shorter than shortFreeze
it fails to fulfill its purpose of extending the shortFreeze
.
The issue is that the Auth.setShortFreeze
and Auth.setLongFreeze
functions do not check that shortFreeze <= longFreeze
.
Therefore I recommend adding the following line to Auth.setShortFreeze
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/mixins/Auth.sol#L180-L184):
require(shortFreeze_ <= longFreeze, "long freeze cannot be shorter than short freeze")
And add the following line to Àuth.setLongFreeze
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/mixins/Auth.sol#L187-L191):
require(shortFreeze <= longFreeze_, "long freeze cannot be shorter than short freeze")
In order for the change to work you need to change the order in which you set the short freeze and long freeze values in the Auth.__Auth_init
function.
You need to set the long freeze first, then the short freeze:
setLongFreeze(longFreeze_); setShortFreeze(shortFreeze_);
RToken
contract does not receive rToken
distributionThe Distributor
is used to distribute rsr
and rToken
to destination addresses.
When calling the Distributor.setDistribution
function (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/Distributor.sol#L61-L65) it should be checked that the destination is not the RToken
contract.
This is because the RToken
contract will revert if it receives rToken
(https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L833). So the RToken
contract can be mistakenly registered thereby causing DOS to the Distributor
.
Also the RToken
contract is not able to handle any rsr
it receives so you can just forbid registering the RToken
contract as destination.
The RToken.issue
function can be called for any recipient (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L186).
So any user can issue rToken
for any other user.
This can lead to scenarios where malicious users intentionally issue a very small amount of rToken
to other users in order to spam them.
E.g. if there is an application with a GUI, other users' GUIs can be spammed with multiple issuances that they did not initiate.
This can negatively impact user experience.
It was assessed with the sponsor that they need to have this design and the benefits outweigh the risks.
However this should be kept in mind when building applications around the issuance functionality because they need to deal with this potential spamming.
delegatecall
Assets are registered in the core protocol via the AssetRegistry
contract. The core protocol components can then claim rewards for an asset they hold by calling Asset.claimRewards
via delegatecall
.
For example the claimRewards
function in the CTokenFiatCollateral
Asset looks like this (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/plugins/assets/CTokenFiatCollateral.sol#L52-L57):
function claimRewards() external virtual override { IERC20 comp = IERC20(comptroller.getCompAddress()); uint256 oldBal = comp.balanceOf(address(this)); comptroller.claimComp(address(this)); emit RewardsClaimed(comp, comp.balanceOf(address(this)) - oldBal); }
The claimRewards
function is dependent on the comptroller
state variable of the CTokenFiatCollateral
contract.
The only reason this state variable can be accessed even though the claimRewards
function is called via delegateCall
is that comptroller
is an immutable
variable.
So the current plugin implementations are dependent on their claimRewards
functions only accessing immutable
variables.
If they try to access regular mutable variables, they will operate on the storage of the caller which can lead to various flaws.
There is no documentation about this requirement for immutable
variables to be used and the sponsor admitted that this behavior about immutable
variables has not been known before and the plugin architecture worked essentially "by accident".
It was discussed with the sponsor that either the architecture of plugins need to be changed or at least the documentation should be improved to make this very clear to plugin developers.
delayUntilDefault
Currently the delayUntilDefault
variable in the FiatCollateral
contract is only checked to be greater zero:
This is unsafe because if it is a very large value it can be > type(uint48).max
after block.timestamp
is added to it.
This means _whenDefault
is always set to NEVER
:
This in turn means that the collateral cannot default.
I suggest adding a reasonable upper limit to the delayUntilDefault
variable. Maybe 1 year or 1 month.
In the AssetRegistry.swapRegistered
function, the basket is disabled if the quantity of the asset is greater 0:
It is not necessary to disable the basket if the new asset is the same as the old basket.
Not disabling the basket in this case improves the availability of nearly all operations in the protocol since most of them are dependent on a valid basket.
I suggest changing the function to this:
function swapRegistered(IAsset asset) external governance returns (bool swapped) { require(_erc20s.contains(address(asset.erc20())), "no ERC20 collision"); ++++ if (assets[asset.erc20()] != asset) { uint192 quantity = basketHandler.quantity(asset.erc20()); if (quantity > 0) basketHandler.disableBasket(); ++++ } swapped = _registerIgnoringCollisions(asset); }
lotLow
price instead of low
price to calculate total asset valueCurrently the RecollateralizationLib.totalAssetValue
function makes use of the low
price to calculate the total asset value under management by the BackingManager:
You should consider using the lotLow
price instead. This is since the purpose of the lotLow
price is to provide a price that decays to zero if there is something wrong with the price feed. The low
price however will just be 0
when there is something wrong with the price feed. The lotLow
price therefore more accurately reflects the true value of the asset.
FLOOR
instead of CEIL
in calculation of range.bottom
In the RecollateralizationLib.basketRange
function, range.bottom
is calculated like this:
range.bottom = fixMin(assetsLow.div(basketPriceHigh, CEIL), range.top);
The calculation is using rounding mode CEIL
. This is wrong because range.bottom
represents the low value of the range. Therefore the calculation should round down, i.e. use rounding mode FLOOR
.
sell
or buy
tokens to GnosisTrade
contract such that reportViolation
is not calledThe GnosisTrade
contract calls Broker.reportViolation
when the clearingPrice
is less than the worstCasePrice
:
This disables the Broker
such that no new trades can be opened until the issue is resolved by governance.
In a case where the GnosisTrade
contract would call Broker.reportViolation
this can be avoided by sending sell
tokens or buy
tokens (possible a very small amount is sufficient) to the GnosisTrade
contract.
This behavior can prevent governance and the protocol from noticing an issue as it emerges (maybe a small amount of funds can manipulate the price sufficiently to keep the protocol going). The governance then only notices the problem as it gets worse.
The sold amount and bought amount should be queried from the EasyAuction
contract directly instead of relying on the GnosisTrade
contract balance.
minBuyAmountPerOrder
calculation should be based on asset value not token fractionThe minBuyAmountPerOrder
in the GnosisTrade.init
function is calculated like this:
uint256 minBuyAmtPerOrder = Math.max( minBuyAmount / MAX_ORDERS, DEFAULT_MIN_BID.shiftl_toUint(int8(buy.decimals())) );
So it is at least as big as DEFAULT_MIN_BID.shiftl_toUint(int8(buy.decimals()))
, and DEFAULT_MIN_BID
is one hundredth of a token (https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/plugins/trading/GnosisTrade.sol#L34).
This amount can cause problems for some tokens like WBTC. Currently one hundredth of one WBTC is worth >$200.
If the assets sold are worth only $100 then nobody is going to buy for $200.
I suggest the minBuyAmountPerOrder
to be calculated based on the value of the asset that is sold in case the asset can be priced. Only in the case that the asset is unpriced must there be a fallback mechanism.
Main
All components of the protocol are registered in the Main
contract. It is not always necessary to access components by calling the Main
contract first.
Specifically the p1
implementation follows the style to only access components via Main
when the component is not accessible from the local contract.
Here are the instances that should be changed:
Use components.stRSR
instead of main.stRSR()
:
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/Deployer.sol#L208
Use furnace
instead of main.furnace()
:
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L452
Use assetRegistry
instead of main.assetRegistry()
:
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L380
You should consider using Solidity units (https://docs.soliditylang.org/en/v0.8.17/units-and-global-variables.html) whenever possible to improve readability of the code.
Use 1 weeks
instead of 604800
:
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/Broker.sol#L24
In the RToken.issue
function, the collateral status is checked two times:
The second check is redundant because it will always be true as made sure by the first check.
Unstaking rsr
from the StRSR
contract is performed by first calling unstake
and then after an unstakingDelay
calling withdraw
.
This means that when a user calls unstake
, he MUST wait the unstakingDelay
and then he MUST call withdraw
.
A user might decide to restake his rsr
at a time when unstakingDelay
has not passed yet and thereby continue earning rewards.
It was discussed with the sponsor that they will consider adding such a restake
functionality in order to provide more flexibility.
The incentives involved with the staking/unstaking
mechanism likely don't change much.
It might make staking even somewhat more attractive because of the added flexibility.
Currently the FiatCollateral
contract implements a symmetric range in which targetPerRef
values are allowed for the collateral to be SOUND
. This means the same delta is used to calculate the pegBottom
and pegTop
sides:
It can be beneficial for some FiatCollateral
assets to allow a higher deviation on one side than on the other side.
E.g. the USD/USDT peg might be considered valid in a range from 0.95 to 1.02 with the asymmetric range instead of 0.95 - 1.05 with the symmetric range.
DISABLED
when it has IFFY
collateral and allowing redemption when basket is DISABLED
It was discussed with the Sponsor whether it is correct how the BasketHandler
handles IFFY
collateral.
I brought up the following concern:
In the BasketHandler._switchBasket function, a collateral with the status IFFY is considered a good collateral and is added to the reference basket. Some operations however like StRSR.withdraw require that there is no IFFY collateral in the basket.
In the discussion with the sponsor it was determined that:
So it might be that leaving the basket DISABLED and enabling redemption on DISABLED baskets makes more sense
There is no vulnerability with the current behavior but it might be beneficial to change the behavior of the protocol as stated above.
The sponsor should therefore further assess the proposed change and implement it if it is beneficial.
IStRSRVotes
instead of IStRSR
interfaceThe Deployer
is used to deploy an instance of a RToken.
This is achieved by cloning an implementation contract of each contract that is needed.
One of the contracts is called stRSR
and it must implement the IStRSR
interface:
This is too permissive though because there are two contracts that implement the IStRSR
interface: StRSR
and StRSRVotes
.
The RToken protocol however requires that the contract is StRSRVotes
not StRSR
(voting capabilities are needed for governance).
The Deployer
contract therefore should use IStRSRVotes
instead of IStRSR
such that the implementation contract must implement IStRSRVotes
.
#0 - c4-judge
2023-01-25T02:40:09Z
0xean marked the issue as grade-a