Platform: Code4rena
Start Date: 29/03/2024
Pot Size: $36,500 USDC
Total HM: 5
Participants: 72
Period: 5 days
Judge: 3docSec
Total Solo HM: 1
Id: 357
League: ETH
Rank: 3/72
Findings: 2
Award: $3,177.06
🌟 Selected for report: 1
🚀 Solo Findings: 0
3028.5531 USDC - $3,028.55
https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L306-L323 https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L399-L400 https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L455
If USDC depegs OUSG
can be bought at a discount or sold at a premium depending on which way the depeg is.
As the mints and redeems are instant this could be used for arbitrage, even though that seems to be mitigated by off chain agreements.
ousgInstantManager
uses an oracle to evaluate the price of OUSG
, which in turn relies on the SHV/USD
chainlink feed.
The issue here is that the SHV/USD
chainlink oracle is denominated in USD
but ousgInstantManager
uses this as 1:1 with USDC
in [_mint
}(https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L307-L319):
File: contracts/ousg/ousgInstantManager.sol // price is queried in USD 307: uint256 ousgPrice = getOUSGPrice(); 308: ousgAmountOut = _getMintAmount(usdcAmountAfterFee, ousgPrice); ... // payment is taken in USDC 319: usdc.transferFrom(msg.sender, usdcReceiver, usdcAmountAfterFee);
and _redeem
:
File: contracts/ousg/ousgInstantManager.sol // the USD amount of OUSG is used 399: uint256 ousgPrice = getOUSGPrice(); 400: uint256 usdcAmountToRedeem = _getRedemptionAmount(ousgAmountIn, ousgPrice); ... // but funding done in USDC 455: usdc.transfer(msg.sender, usdcAmountOut);
USDC
doesn't always follow USD
1:1. It can depeg occasionally. Most famously on March 11th 2023 when it traded well under $1 for an extended period of time.
Were something similar to happen a user would be able to buy OUSG
at a discount if USDC
is below peg. By buying USDC
cheap and then getting its USD
value in OUSG
. Or you could sell OUSG
at a premium if USDC
was above peg, since they could then sell the USDC
for higher value in USD
.
Manual audit
Consider implementing an off chain monitoring that pauses ousgInstantManager
were a large enough depeg to happen. Or use a more complex oracle with a comparison of USDC/USD
as well.
Oracle
#0 - c4-pre-sort
2024-04-04T04:23:20Z
0xRobocop marked the issue as duplicate of #297
#1 - c4-judge
2024-04-09T10:03:49Z
3docSec changed the severity to 3 (High Risk)
#2 - c4-judge
2024-04-09T10:06:24Z
3docSec marked the issue as satisfactory
🌟 Selected for report: immeas
Also found by: 0xAkira, 0xCiphky, 0xGreyWolf, 0xJaeger, 0xMosh, 0xabhay, 0xlemon, 0xmystery, 0xweb3boy, Aamir, Abdessamed, Aymen0909, Breeje, DanielArmstrong, DarkTower, Dots, EaglesSecurity, FastChecker, HChang26, Honour, IceBear, JC, K42, Krace, MaslarovK, Omik, OxTenma, SAQ, Shubham, Stormreckson, Tigerfrake, Tychai0s, VAD37, ZanyBonzy, albahaca, arnie, ast3ros, asui, b0g0, bareli, baz1ka, btk, caglankaan, carrotsmuggler, cheatc0d3, dd0x7e8, grearlake, igbinosuneric, jaydhales, kaden, kartik_giri_47538, m4ttm, ni8mare, niser93, nonn_ac, oualidpro, pfapostol, pkqs90, popeye, radev_sw, samuraii77, slvDev, zabihullahazadzoi
148.5132 USDC - $148.51
id | title |
---|---|
L-01 | no oracle price staleness checks |
L-02 | user blocked by Circle cannot redeem |
L-03 | usdcReceiver is immutable |
L-04 | ousgInstantManager::mint /redeem lacks slippage parameter |
L-05 | lack of safe transfer wrapper |
NC-01 | unnecessary checks |
NC-02 | usdcfees doesn't follow camelCase |
NC-03 | inconsistent address(0) checks |
NC-04 | erroneous math in documentation |
NC-05 | misspelled parameter |
Both ousgInstantManager
and rOUSG
uses an oracle to determine the price of OUSG
.
File: contracts/ousg/rOUSG.sol 378: function getOUSGPrice() public view returns (uint256 price) { 379: (price, ) = oracle.getPriceData(); 380: }
Very similar in ousgInstantManager::getOUSGPrice
but with a lowest price check.
Here only the first param, price
is used. However the second parameter returned is the priceTimestamp
. Which is the timestamp at which the price was updated.
If this is old it can lead to incorrect OUSG
prices used for rOUSG
or instant minting
/redeeming
.
Consider adding a check that the price used isn't stale.
When instant redeeming either OUSG
or rOUSG
, at the end the user is funded their USDC
ousgInstantManager::_redeem
:
File: contracts/ousg/ousgInstantManager.sol 455: usdc.transfer(msg.sender, usdcAmountOut);
The issue is that Circle (owner of USDC
) can add addresses to a blocklist. If the user holding OUSG
(or rOUSG
) is blocked by Circle they will never be able to redeem.
Consider adding an address to
for redemptions so that they can send the USDC
to an address that is not blocked by Circle.
usdcReceiver
is immutableWhenever someone mints, their USDC
payment is sent to the address usdcReciver
, ousgInstantManager::_mint
:
File: contracts/ousg/ousgInstantManager.sol 319: usdc.transferFrom(msg.sender, usdcReceiver, usdcAmountAfterFee);
The issue is that usdcReceiver
is immutable:
File: contracts/ousg/ousgInstantManager.sol 90: address public immutable usdcReceiver;
Where this address to be blocked by Circle minting in ousgInstantManager
would stop to work and a new ousgInstantManager
would have to be deployed using a new usdcReceiver
. This could be confusing for users.
Consider making usdcReceiver
mutable and add a way for the protocol to change it.
ousgInstantManager::mint
/redeem
lacks slippage parameterIn ousgInstantManager
you can mint either rOUSG
or OUSG
using USDC
and then redeem back to USDC
. Both of these use an oracle to track the price of OUSG
. This price can vary between when a transaction is sent to when it is executed.
This can cause a user to mint or redeem at a different price than they intended.
Consider adding a minOut
parameter for ousgInstantManager::mint
and redeem
calls.
In ousgInstantManager
the admin can make a call to transfer any tokens out of the contract, ousgInstantManager::retrieveTokens
:
File: contracts/ousg/ousgInstantManager.sol 819: function retrieveTokens( 820: address token, 821: address to, 822: uint256 amount 823: ) external onlyRole(DEFAULT_ADMIN_ROLE) { 824: IERC20(token).transfer(to, amount); 825: }
Some tokens behave weirdly when transferred and need some extra attention.
Consider using OZ safeTransfer
wrapper to transfer tokens for better compatibility with different tokens
File: contracts/ousg/ousgInstantManager.sol 415: uint256 usdcFees = _getInstantRedemptionFees(usdcAmountToRedeem); 416: usdcAmountOut = usdcAmountToRedeem - usdcFees; 417: require( 418: usdcAmountOut > 0, 419: "OUSGInstantManager::_redeem: redeem amount can't be zero" 420: );
usdcAmountOut
can never be 0
here. As there is already a check that usdcAmountToRedeem
is greater than minimumRedemptionAmount
and minimumRedemptionAmount
can be at the lowest 10_000
.
The redeemFee
can also be no bigger than 2%.
Hence, usdcAmountOut
can never be lower than 9_800
(10_000 - ((10_000 * 200) / 10_000)
).
The same logic applies to the ousgAmountOut
in _mint
, however the math is a bit more complicated since the lowest possible 9_800
usdc is converted to OUSG. However, due to the price having a lower cap, neither this can ever be 0
.
Consider removing these two checks.
usdcfees
doesn't follow camelCaseFile: contracts/ousg/ousgInstantManager.sol 303: uint256 usdcfees = _getInstantMintFees(usdcAmountIn);
Here usdcfees
isn't camel cased. In _redeem
the same variable is using camel case, which is the naming convention used throughout the code.
Consider using camel case for usdcfees
in _mint
as well.
address(0)
checksousgInstantManager::setOracle
, setFeeReceiver
and setInvestorBasedRateLimiter
all lets admin change various addresses to external contracts.
However, only setFeeReceiver
checks for address(0)
before assigning.
Consider checking for address(0)
in all or none of the calls to have consistent behavior.
In the documentation for rOUSG
it says:
rOUSG#L36-L45
:
File: contracts/ousg/rOUSG.sol 36: * For example, assume that we have: 37: * 38: * ousgPrice = 100.505 39: * sharesOf(user1) -> 100 40: * sharesOf(user2) -> 400 41: * 42: * Therefore: 43: * 44: * balanceOf(user1) -> 105 tokens which corresponds 105 rOUSG 45: * balanceOf(user2) -> 420 tokens which corresponds 420 rOUSG
This is confusing as firstly, one share is one ten-thousandth of a OUSG
hence it is unclear what a "share" means here. And the math is wrong, 100 * 100.505 / 100 = 100.505
and 400 * 100.505 / 100 = 402.02
.
Consider updating the documentation.
ousgInstantManager::setInstantRedemptionLimitDuration
:
File: contracts/ousg/ousgInstantManager.sol 540: function setInstantRedemptionLimitDuration( 541: uint256 _instantRedemptionLimitDuratioin 542: ) external override onlyRole(CONFIGURER_ROLE) { 543: _setInstantRedemptionLimitDuration(_instantRedemptionLimitDuratioin); 544: }
Here _instantRedemptionLimitDuratioin
is misspelled, also in the natspec for the call.
Consider changing it to _instantRedemptionLimitDuration
#0 - c4-pre-sort
2024-04-05T05:13:50Z
0xRobocop marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-04-05T20:16:50Z
0xRobocop marked the issue as high quality report
#2 - cameronclifton
2024-04-05T22:48:26Z
L-01 no oracle price staleness checks - the cadence at which the OUSG price is updated can not be assumed.
L-02 user blocked by Circle cannot redeem - known and intended L-03 usdcReceiver is immutable -Explicit design decision to reduce attack surface L-04 ousgInstantManager::mint/redeem lacks slippage parameter - see other responses regarding slippage L-05 lack of safe transfer wrapper I don't really understand how the linked snippet applies, perhaps an example could be shared. I don't think we want to constrain what tokens we can rescue by adding safeTransfer here
All NC's - Great catches thanks
#3 - c4-sponsor
2024-04-05T22:48:29Z
cameronclifton (sponsor) confirmed
#4 - c4-judge
2024-04-10T07:11:42Z
3docSec marked the issue as selected for report
#5 - 3docSec
2024-04-10T07:15:27Z
Wrt the other issues reviewed by the sponsor, they mostly relate to design choices but are all worth including in the audit report, and their severity seems appropriate.
For reporting, please do not include L-04 (that will be covered by #88). Update: L-04 has been downgraded to QA so all of the findings reported here can be included in our report.
#6 - cameronclifton
2024-04-10T16:34:16Z
@3docSec , this is 10 different findings, are you saying that all except L04 will be included in report?
#7 - 3docSec
2024-04-11T06:57:40Z
@cameronclifton yes, that's what I am asking the reporting team. They are all reasonable Low and NC suggestions. L-04 will already be included in a separate M finding, so no need to re-report it as L. Ls and NCs are often coming from design decisions / accepted risks, does this sound reasonable?
#8 - cameronclifton
2024-04-11T19:54:47Z
@3docSec I think the only one that i'd like to hear more about is L5. I don't think we want to constrain what tokens we can rescue by adding safeTransfer here.
#9 - 3docSec
2024-04-11T20:10:21Z
Hi @cameronclifton the SafeTransfer library is one that you can use to maximize compatibility. For example if you direct call transfer on a non-standard token that doesn’t return a boolean, the call as-is reverts. Instead the SafeTransfer library handles that by wrapping the transfer call in a very nuanced manner that handles reverts, return nothin, return false etc.
So using SafeTransfer will maximize, not limit, the tokens you can rescue with the retrieve function.
#10 - 3docSec
2024-04-11T20:12:22Z
You can check out the code here
#11 - cameronclifton
2024-04-22T22:49:28Z
L1 - “Sponsor will not be addressing and states that the existing functionality is desired.” L2 - “Sponsor will not be addressing.” L3 - “Addressed by sponsor, usdcReceiver is now settable.” L4 - “Sponsor will not be addressing.” L5 - “Sponsor will not be addressing.”
All NCs - “Addressed by Sponsor”