Platform: Code4rena
Start Date: 15/06/2023
Pot Size: $79,800 USDC
Total HM: 14
Participants: 6
Period: 14 days
Judge: 0xean
Total Solo HM: 11
Id: 248
League: ETH
Rank: 6/6
Findings: 5
Award: $0.00
🌟 Selected for report: 5
🚀 Solo Findings: 3
🌟 Selected for report: ronnyx2017
Data not available
There is a dev comment in the Assert.sol:
DEPRECATED: claimRewards() will be removed from all assets and collateral plugins
The claimRewards is moved to the TradingP1.claimRewards/claimRewardsSingle
.
But when the RevenueTraderP1
trade and distribute revenues by manageToken
, it only calls the refresh function of the asserts:
if (erc20 != IERC20(address(rToken)) && tokenToBuy != IERC20(address(rToken))) { IAsset sell_ = assetRegistry.toAsset(erc20); IAsset buy_ = assetRegistry.toAsset(tokenToBuy); if (sell_.lastSave() != uint48(block.timestamp)) sell_.refresh(); if (buy_.lastSave() != uint48(block.timestamp)) buy_.refresh(); }
The claimRewards is left out.
Loss a part of rewards.
Manual review
Add claimRewardsSingle when refresh assert in the manageToken
.
Context
#0 - tbrent
2023-06-12T20:10:30Z
This is similar to an (unmitigated) issue from an earlier contest: https://github.com/code-423n4/2023-02-reserve-mitigation-contest-findings/issues/22
However in this case it has to do with RevenueTraderP1.manageToken()
, as opposed to BackingManagerP1.manageTokens()
.
I think that difference matters, because the loss of the rewards for this auction does not have serious long-term consequences. This is not like the BackingManager where it's important that all capital always be available else an unnecessarily large haircut could occur. Instead, the worst that can happen is for the revenue auction to complete at high slippage, and for a second reward token revenue auction to complete afterwards at high slippage yet again, when it could have been a single revenue auction with less slippage.
#1 - tbrent
2023-06-12T20:11:40Z
The recommended mitigation would not succeed, because recall, we may be selling token X but any number of additional assets could have token X as a reward token. We would need to call claimRewards()
, which is simply too gas-costly to do everytime for revenue auctions.
#2 - c4-sponsor
2023-07-04T23:03:49Z
tbrent marked the issue as sponsor disputed
#3 - 0xean
2023-07-12T19:44:49Z
Instead, the worst that can happen is for the revenue auction to complete at high slippage, and for a second reward token revenue auction to complete afterwards at high slippage yet again, when it could have been a single revenue auction with less slippage.
The impact sounds like a "leak of value" and therefore I think M is the correct severity per the c4 docs. (cc @tbrent - open to additional comment here)
#4 - c4-judge
2023-07-15T13:05:01Z
0xean marked the issue as satisfactory
🌟 Selected for report: ronnyx2017
Data not available
When creating the trade for rebalance, the RecollateralizationLibP1.nextTradePair
uses (uint192 low, uint192 high) = rsrAsset.price(); // {UoA/tok}
to get the rsr sell price. And the rsr assert is a pure Assert contract, which price()
function will just return (0, FIX_MAX) if oracle is timeout:
function price() public view virtual returns (uint192, uint192) { try this.tryPrice() returns (uint192 low, uint192 high, uint192) { assert(low <= high); return (low, high); } catch (bytes memory errData) { ... return (0, FIX_MAX); } }
The trade.sellAmount
will be all the rsr in the BackingManager
and stRSR
:
uint192 rsrAvailable = rsrAsset.bal(address(ctx.bm)).plus( rsrAsset.bal(address(ctx.stRSR)) ); trade.sellAmount = rsrAvailable;
It will be cut down to a normal amount fit for buying UoA amount in the trade.prepareTradeToCoverDeficit
function.
But if the rsr oracle is timeout and returns a 0 low price. The trade req will be made by trade.prepareTradeSell
, which will sell all the available rsr at 0 price.
Note that the SOUND colls won't be affected by the issue because the sell amount has already been cut down by basketsNeeded.
Loss huge amount of rsr in the auction. When huge amounts of assets are auctioned off at zero, panic and insufficient liquidity make the outcome unpredictable.
POC git diff test/Recollateralization.test.ts
diff --git a/test/Recollateralization.test.ts b/test/Recollateralization.test.ts index 86cd3e88..15639916 100644 --- a/test/Recollateralization.test.ts +++ b/test/Recollateralization.test.ts @@ -51,7 +51,7 @@ import { import snapshotGasCost from './utils/snapshotGasCost' import { expectTrade, getTrade, dutchBuyAmount } from './utils/trades' import { withinQuad } from './utils/matchers' -import { expectRTokenPrice, setOraclePrice } from './utils/oracles' +import { expectRTokenPrice, setInvalidOracleTimestamp, setOraclePrice } from './utils/oracles' import { useEnv } from '#/utils/env' import { mintCollaterals } from './utils/tokens' @@ -797,6 +797,166 @@ describe(`Recollateralization - P${IMPLEMENTATION}`, () => { }) describe('Recollateralization', function () { + context('With simple Basket - Two stablecoins', function () { + let issueAmount: BigNumber + let stakeAmount: BigNumber + + beforeEach(async function () { + // Issue some RTokens to user + issueAmount = bn('100e18') + stakeAmount = bn('10000e18') + + // Setup new basket with token0 & token1 + await basketHandler.connect(owner).setPrimeBasket([token0.address, token1.address], [fp('0.5'), fp('0.5')]) + await basketHandler.connect(owner).refreshBasket() + + // Provide approvals + await token0.connect(addr1).approve(rToken.address, initialBal) + await token1.connect(addr1).approve(rToken.address, initialBal) + + // Issue rTokens + await rToken.connect(addr1).issue(issueAmount) + + // Stake some RSR + await rsr.connect(owner).mint(addr1.address, initialBal) + await rsr.connect(addr1).approve(stRSR.address, stakeAmount) + await stRSR.connect(addr1).stake(stakeAmount) + }) + + it('C4M7', async () => { + // Register Collateral + await assetRegistry.connect(owner).register(backupCollateral1.address) + + // Set backup configuration - USDT as backup + await basketHandler + .connect(owner) + .setBackupConfig(ethers.utils.formatBytes32String('USD'), bn(1), [backupToken1.address]) + + // Set Token0 to default - 50% price reduction + await setOraclePrice(collateral0.address, bn('0.5e8')) + + // Mark default as probable + await assetRegistry.refresh() + // Advance time post collateral's default delay + await advanceTime((await collateral0.delayUntilDefault()).toString()) + + // Confirm default and trigger basket switch + await basketHandler.refreshBasket() + + // Advance time post warmup period - SOUND just regained + await advanceTime(Number(config.warmupPeriod) + 1) + + const initToken1B = await token1.balanceOf(backingManager.address); + // rebalance + const token1Decimal = 6; + const sellAmt: BigNumber = await token0.balanceOf(backingManager.address) + const buyAmt: BigNumber = sellAmt.div(2) + await facadeTest.runAuctionsForAllTraders(rToken.address); + // bid + await backupToken1.connect(addr1).approve(gnosis.address, sellAmt) + await gnosis.placeBid(0, { + bidder: addr1.address, + sellAmount: sellAmt, + buyAmount: buyAmt, + }) + await advanceTime(config.batchAuctionLength.add(100).toString()) + // await facadeTest.runAuctionsForAllTraders(rToken.address); + const rsrAssert = await assetRegistry.callStatic.toAsset(rsr.address); + await setInvalidOracleTimestamp(rsrAssert); + await expectEvents(facadeTest.runAuctionsForAllTraders(rToken.address), [ + { + contract: backingManager, + name: 'TradeSettled', + args: [anyValue, token0.address, backupToken1.address, sellAmt, buyAmt], + emitted: true, + }, + { + contract: backingManager, + name: 'TradeStarted', + args: [anyValue, rsr.address, backupToken1.address, stakeAmount, anyValue], // sell 25762677277828792981 + emitted: true, + }, + ]) + + // check + console.log(await token0.balanceOf(backingManager.address)); + const currentToken1B = await token1.balanceOf(backingManager.address); + console.log(currentToken1B); + console.log(await backupToken1.balanceOf(backingManager.address)); + const rsrB = await rsr.balanceOf(stRSR.address); + console.log(rsrB); + + // expect + expect(rsrB).to.eq(0); + }) + }) + context('With very simple Basket - Single stablecoin', function () { let issueAmount: BigNumber let stakeAmount: BigNumber
run test:
PROTO_IMPL=1 npx hardhat test --grep 'C4M7' test/Recollateralization.test.ts
log:
Recollateralization - P1 Recollateralization With simple Basket - Two stablecoins BigNumber { value: "0" } BigNumber { value: "50000000" } BigNumber { value: "25000000000000000000" } BigNumber { value: "0" }
Manual review
Using lotPrice or just revert for rsr oracle timeout might be a good idea.
Context
#0 - tbrent
2023-06-12T20:32:13Z
Hmm, interesting case.
There are two types of auctions that can occur: batch auctions via GnosisTrade
, and dutch auctions via DutchTrade
.
Batch auctions via GnosisTrade
are good at discovering prices when price is unknown. It would require self-interested parties to be offline for the entire duration of the batch auction (default: 15 minutes) in order for someone to get away with buying the RSR for close to 0.
Dutch auctions via DutchTrade
do not have this problem because of an assert that reverts at the top of the contract.
I'm inclined to dispute validity, but I also agree it might be strictly better to use the lotPrice()
. When trading out backing collateral it is important to sell it quickly and not have to wait for lotPrice()
to decay sufficiently, but this is not true with RSR. For RSR it might be fine to wait as long as a week for the lotPrice()
to fall to near 0.
This would then allow dutch auctions via DutchTrade
to be used when RSR's oracle is offline.
#1 - c4-sponsor
2023-07-04T23:04:14Z
tbrent marked the issue as sponsor confirmed
#2 - c4-judge
2023-07-12T19:45:36Z
0xean marked the issue as satisfactory
🌟 Selected for report: ronnyx2017
Data not available
The reward rToken sent to RevenueTrader will be sold at a low price. RSR stakers will lose some of their profits.
RevenueTraderP1.manageToken
function is used to launch auctions for any erc20 tokens sent to it. For the RevenueTrader of the rsr stake, the tokenToBuy
is rsr and the token to sell is reward rtoken.
There is the refresh code in the manageToken
function:
} else if (assetRegistry.lastRefresh() != uint48(block.timestamp)) { // Refresh everything only if RToken is being traded assetRegistry.refresh(); furnace.melt(); }
It refreshes only when the assetRegistry has not been refreshed in the same block.
So if the actor calls the assetRegistry.refresh()
before calling manageToken
function, the furnace.melt()
won't been called. And the BU exchange rate of the RToken will be lower than actual value. So the sellPrice is also going to be smaller.
(uint192 sellPrice, ) = sell.price(); // {UoA/tok} TradeInfo memory trade = TradeInfo({ sell: sell, buy: buy, sellAmount: sell.bal(address(this)), buyAmount: 0, sellPrice: sellPrice, buyPrice: buyPrice });
Manual review
Refresh everything before sell rewards.
Context
#0 - c4-sponsor
2023-07-04T23:11:04Z
tbrent marked the issue as sponsor confirmed
#1 - c4-judge
2023-07-12T19:46:35Z
0xean marked the issue as satisfactory
🌟 Selected for report: ronnyx2017
Also found by: 0xA5DF, rvierdiiev
Data not available
If the system is frozen, the only allowed operation is stRST.stake
. And the _payoutRewards
is not called during freeze period:
if (!main.frozen()) _payoutRewards(); function payoutRewards() external { requireNotFrozen(); _payoutRewards(); }
So the payoutLastPaid
stays before the freeze period. But when the system is unfreezed, accumulated rewards will be released all at once because the block.timestamp leapt the whole freeze period.
A front runner can stake huge proportion rsr before admin unfreezes the system. And the attacker can get most of rsr rewards in the next block. And he only takes the risk of the unstakingDelay
period.
Assumption: there are 2000 rsr stake in the stRSR, and there are 1000 rsr rewards in the rsrRewardsAtLastPayout
with a 1 year half-life period.
And at present, the LONG_FREEZER freezeLong
system for 1 year(default).
After 1 year, at the unfreeze point, a front runner stake 2000 rsr into stRSR. And then the system is unfreeze. And in the next blcok,the front runner unstakes all the stRSR he has for 2250 rsr = 2000 principal + 1000 / 2 / 2 rsr rewards
.
The only risk he took is unstakingDelay
. The original rsr stakers took the risk of the whole freeze period + unstakingDelay
but only got a part of rewards back.
Manual review
payoutRewards before freeze and update payoutLastPaid before unfreeze.
Access Control
#0 - c4-judge
2023-06-12T12:54:42Z
0xean marked the issue as duplicate of #24
#1 - c4-judge
2023-07-12T20:04:55Z
0xean marked the issue as satisfactory
#2 - c4-judge
2023-07-15T13:06:49Z
0xean marked the issue as selected for report
🌟 Selected for report: ronnyx2017
Also found by: 0xA5DF, rvierdiiev
Data not available
cancelUnstake
will cancel the withdrawal request in the queue can mint shares as the current stakeRate
. But it doesn't payoutRewards
before mintStakes
. Therefor it will mint stRsr as a lower rate, which means it will get more rsr.
Withdrawers in the unstake queue can cancelUnstake
without calling payoutRewards
to get more rsr rewards that should not belong to them.
POC test/ZZStRSR.test.ts git patch
diff --git a/test/ZZStRSR.test.ts b/test/ZZStRSR.test.ts index ecc31f68..b2809129 100644 --- a/test/ZZStRSR.test.ts +++ b/test/ZZStRSR.test.ts @@ -1333,6 +1333,46 @@ describe(`StRSRP${IMPLEMENTATION} contract`, () => { expect(await stRSR.exchangeRate()).to.be.gt(initialRate) }) + it('cancelUnstake', async () => { + const amount: BigNumber = bn('10e18') + + // Stake + await rsr.connect(addr1).approve(stRSR.address, amount) + await stRSR.connect(addr1).stake(amount) + await rsr.connect(addr2).approve(stRSR.address, amount) + await stRSR.connect(addr2).stake(amount) + await rsr.connect(addr3).approve(stRSR.address, amount) + await stRSR.connect(addr3).stake(amount) + + const initExchangeRate = await stRSR.exchangeRate(); + console.log(initExchangeRate); + + // Unstake addr2 & addr3 at same time (Although in different blocks, but timestamp only 1s) + await stRSR.connect(addr2).unstake(amount) + await stRSR.connect(addr3).unstake(amount) + + // skip 1000 block PERIOD / 12000s + await setNextBlockTimestamp(Number(ONE_PERIOD.mul(1000).add(await getLatestBlockTimestamp()))) + + // Let's cancel the unstake in normal + await expect(stRSR.connect(addr2).cancelUnstake(1)).to.emit(stRSR, 'UnstakingCancelled') + let exchangeRate = await stRSR.exchangeRate(); + expect(exchangeRate).to.equal(initExchangeRate) + + // addr3 cancelUnstake after payoutRewards + await stRSR.payoutRewards() + await expect(stRSR.connect(addr3).cancelUnstake(1)).to.emit(stRSR, 'UnstakingCancelled') + + // Check balances addr2 & addr3 + exchangeRate = await stRSR.exchangeRate(); + expect(exchangeRate).to.be.gt(initExchangeRate) + const addr2NowAmount = exchangeRate.mul(await stRSR.balanceOf(addr2.address)).div(bn('1e18')); + console.log("addr2", addr2NowAmount.toString()); + const addr3NowAmount = exchangeRate.mul(await stRSR.balanceOf(addr3.address)).div(bn('1e18')); + console.log("addr3",addr3NowAmount.toString()); + expect(addr2NowAmount).to.gt(addr3NowAmount) + }) + it('Rewards should not be handed out when paused but staking should still work', async () => { await main.connect(owner).pauseTrading() await setNextBlockTimestamp(Number(ONE_PERIOD.add(await getLatestBlockTimestamp())))
The test simulates two users unstake and cancelUnstake operations at the same time.But the addr2 calls payoutRewards after his cancelUnstake. And addr3 calls cancelUnstake after payoutRewards. Addr2 gets more rsr than addr3 in the end.
run test:
PROTO_IMPL=1 npx hardhat test --grep cancelUnstake test/ZZStRSR.test.ts
log:
StRSRP1 contract Add RSR / Rewards BigNumber { value: "1000000000000000000" } addr2 10005345501258588240 addr3 10000000000000000013
Manual review
Call _payoutRewards
before mint shares.
Math
#0 - c4-judge
2023-06-11T20:50:27Z
0xean marked the issue as primary issue
#1 - tbrent
2023-06-13T19:40:30Z
Agree with severity and proposed mitigation.
#2 - c4-sponsor
2023-07-04T23:12:43Z
tbrent marked the issue as sponsor confirmed
#3 - c4-judge
2023-07-12T19:48:24Z
0xean marked the issue as satisfactory
#4 - c4-judge
2023-07-15T13:07:18Z
0xean marked the issue as selected for report
🌟 Selected for report: 0xA5DF
Also found by: RaymondFam, carlitox477, hihen, ronnyx2017, rvierdiiev
Data not available
BackingManagerP1.grantRTokenAllowance
should be limited by notTradingPausedOrFrozen
instead of notFrozen
. Because the asserts approval for RToken are used to redeem, which is disabled when TradingPaused.
Time check is inconsistent in DutchTrade CanSettle:
|| block.timestamp > endTime);
But in settle():
require(block.timestamp >= endTime,
Reward ratio is 0.0000032090147 = 0.00032090147% ,instead of 0.0000032090147% .
#0 - 0xean
2023-07-12T20:04:25Z
Awarding as grade A due to wardens other (downgraded) findings that are included as part of this
#1 - c4-judge
2023-07-12T20:04:30Z
0xean marked the issue as grade-a