Platform: Code4rena
Start Date: 07/09/2022
Pot Size: $20,000 CANTO
Total HM: 7
Participants: 65
Period: 1 day
Judge: 0xean
Total Solo HM: 3
Id: 159
League: ETH
Rank: 5/65
Findings: 3
Award: $1,359.13
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: hickuphh3
Also found by: Critical, __141345__, linmiaomiao, sorrynotsorry
7507.8311 CANTO - $1,212.51
https://github.com/code-423n4/2022-09-canto/blob/main/src/Swap/BaseV1-periphery.sol#L501-L508
The prices of USDC and USDT, which (I assume) are the underlying tokens of cUSDC
and cUSDT
, have been hardcoded to parity. Such practices are highly discouraged because while the likelihood of either stablecoin de-pegging is low, it is not zero.
Because of the UST debacle, the price of USDT dropped to $0.95 before making a recovery.
Here is an example of how a lending protocol on Fantom was affected by such a depeg event because they hardcoded the value.
To quote philosopher George Santayana, “Those who cannot remember the past are condemned to repeat it.”
Consider using a price feed by trusted and established oracle providers like Chainlink, Band Protocol or Flux. The USDC/NOTE or USDT/NOTE price feed may be used as well, but NOTE has its own volatility concerns.
🌟 Selected for report: hickuphh3
Also found by: 0xNazgul, 0xSky, CertoraInc, Deivitto, Jeiwan, SinceJuly, hansfriese, linmiaomiao, rbserver
664.9949 CANTO - $107.40
https://github.com/code-423n4/2022-09-canto/blob/main/src/Swap/BaseV1-periphery.sol#L582
uint token0TVL = assetReserves[i] * (prices[i] / decimals);
Because of the brackets, the division of prices[i] / decimals
is executed before multiplication, causing token0TVL
to potentially be zero.
Add the following test in oracle.test.ts
. Note: getPriceLP()
should have its visibility changed from internal to public as the test relies on it.
To summarise what the test is doing, a stablecoin of 24 decimals is deployed, whose address will be greater than the note
address so that token0 = note
. It will enter the following case:
if (pair.stable()) { // stable pairs will be priced in terms of Note if (token0 == note) { //token0 is the unit, token1 will be priced with respect to this asset initially decimals = 10 ** (erc20(token1).decimals()); // we must normalize the price of token1 to 18 decimals prices = pair.sample(token1, decimals, 8, 1); (unitReserves, assetReserves) = pair.sampleReserves(8, 1);
such that the prices
’s denomination is smaller than the stablecoin’s decimals of 24.
To see the difference in test results, apply the recommended fix after running the test once. In essence, the LP’s price will double from 999500000001499
to 1999999998838589
, which is expected since the LP token should be worth the combined value of both stablecoins.
it.only("will have 0 token0TVL", async () => { // NOTE: change getPriceLP() from internal to public so that function can be called let tokenFactory = await ethers.getContractFactory("ERC20", dep) let stablecoin = await tokenFactory.deploy("STABLE","STABLE",ethers.utils.parseUnits("100000", "24"), 24) await stablecoin.deployed() // we want note to be token0 // redeploy till it is while (stablecoin.address < note.address) { stablecoin = await tokenFactory.deploy("STABLE","STABLE",ethers.utils.parseUnits("100000", "24"), 24) await stablecoin.deployed() } // give token approvals to router let noteIn = ethers.utils.parseUnits("10000", "18") let stableIn = ethers.utils.parseUnits("10000", "24") await (await note.approve(router.address, ethers.constants.MaxUint256)).wait() await (await stablecoin.approve(router.address, ethers.constants.MaxUint256)).wait() // borrow note await (await comptroller._supportMarket(cUsdc.address)).wait() // set collateral factors for cCanto await (await comptroller._setCollateralFactor(cUsdc.address, ethers.utils.parseUnits("0.9", "18"))).wait() // borrow note against usdc await (await comptroller.enterMarkets([cUsdc.address, cNote.address])).wait() await (await usdc.approve(cUsdc.address, ethers.utils.parseUnits("1000"))).wait() // supply usdc await (await cUsdc.mint(ethers.utils.parseUnits("100000000", "6"))).wait() // borrow note await (await cNote.borrow(ethers.utils.parseUnits("9000000", "18"))).wait() // add liquidity await (await router.addLiquidity( note.address, stablecoin.address, true, noteIn, stableIn, 0, 0, dep.address, 9999999999, )).wait() // get pair address let pairAddr = await factory.getPair(note.address, stablecoin.address, true) pair = await ethers.getContractAt("BaseV1Pair", pairAddr) //set period size to zero for instant observations await (await factory.setPeriodSize(0)).wait() // swap 10 times for price observations for(var i = 0; i < 10; i++) { if (i % 2) { //swap 0.01 note for stable await (await router.swapExactTokensForTokensSimple( ethers.utils.parseUnits("10", "18"), 0, note.address, stablecoin.address, true, dep.address, 9999999999999 )).wait() } else { //swap stable for note await (await router.swapExactTokensForTokensSimple( ethers.utils.parseUnits("10", "24"), 0, stablecoin.address, note.address, true, dep.address, 9999999999999 )).wait() } } // check lpToken price // Actual price calculated is 999500000001499 // But expected price (after removing brackets) is 1999999998838589 console.log((await router.getPriceLP(pairAddr)).toString()); });
- uint token0TVL = assetReserves[i] * (prices[i] / decimals); + uint token0TVL = assetReserves[i] * prices[i] / decimals;
🌟 Selected for report: lukris02
Also found by: 0x040, 0x1f8b, 0x52, 0xA5DF, 0xNazgul, 0xSky, Bnke0x0, Bronicle, CertoraInc, Chom, CodingNameKiki, Deivitto, Diraco, Dravee, EthLedger, IgnacioB, JC, JansenC, Jeiwan, R2, RaymondFam, ReyAdmirado, Rolezn, SinceJuly, TomJ, Tomo, Yiko, a12jmx, ajtra, ak1, codexploder, cryptphi, csanuragjain, erictee, fatherOfBlocks, gogo, hake, hansfriese, hickuphh3, ignacio, ontofractal, oyc_109, p_crypt0, pashov, peritoflores, rajatbeladiya, rbserver, rokinot, rvierdiiev, tnevler
242.8216 CANTO - $39.22
token0TVL
and token1TVL
https://github.com/code-423n4/2022-09-canto/blob/main/src/Swap/BaseV1-periphery.sol#L582-L583
The variables token0TVL
and token1TVL
are misleading because assetReserves
and unitReserves
could be either token0
or token1
amounts, depending on whether token0
is the NOTE or CANTO token.
uint token0TVL = assetReserves[i] * (prices[i] / decimals); uint token1TVL = unitReserves[i]; // price of the unit asset is always 1
Consider better names indicating that they are referring to the TVL of the asset and unit tokens. Something like assetTokenTVL
and unitTokenTVL
.
https://github.com/code-423n4/2022-09-canto/blob/main/src/Swap/BaseV1-periphery.sol#L558-L586
The LP token’s value is determined by a 30-minutes arithmetic mean TWAP (am-TWAP) of the past 8 observations.
Hayden from Uniswap tweeted that *“gmTWAP (geometric mean TWAP) is equally expensive to manipulate in both directions under geometric brownish motion, whereas amTWAP overweights upwards movements a huge amount relative to downward movements (making it far easier to manipulate if you know what you’re doing)”.
This is supported by a research paper on TWAP Oracle attacks. I reproduced a portion of the “results” section below.
We compare the three different TWAP manipulation attacks we have seen so far. We again use the example where we want to double the price of an asset which uses a 30-minute TWAP that uses a pair with $2,000,000 of total liquidity reserves. The cost of the MMEV single-block attack with 1.5% hash rate is $160,000. Based on equation 1, the cost of the singleblock attack is C1(135 · 1) = $9,750,000 and the cost of the multi-block attack is 135 · C1(1) = $16,200,000. All attacks ignore the rather small AMM trading fee of around $35,000.
It goes on to present the geometric mean as a solution to the oracle manipulation.
However, calculation of the geometric mean for the current 8 observations is non-trivial because the multiplication of the values will likely lead to overflow. A naiive method would be to square root each observation thrice, then multiply them together. This however might lead to potential rounding errors, with significant gas usage as well.
A more viable solution would be to push for established oracle companies like Chainlink, Band Protocol and Flux to deploy on Canto for usage.
https://github.com/code-423n4/2022-09-canto/blob/main/src/Swap/BaseV1-periphery.sol#L492
https://github.com/code-423n4/2022-09-canto/blob/main/src/Swap/BaseV1-periphery.sol#L558-L586
underlying
assignmentif (compareStrings(symbol, "cCANTO")) { underlying = address(wcanto); // redundant return getPriceNote(address(wcanto), false);
The assignment of wcanto
to the underlying
variable is redundant since it is not used in this branch.
else
caseelse { if (isStable[underlying]) { return getPriceNote(underlying, true); // value has already been scaled } return getPriceCanto(underlying) * getPriceNote(address(wcanto), false) / 1e18; }
The else
case here is redundant and thus can be dropped because the if
case returns early.
isPriceOracle
indicatorhttps://github.com/Canto-Network/clm/blob/main/src/PriceOracle.sol#L7-L8
There is an isPriceOracle
constant that is present in the PriceOracle
contract. Consider having it included in the periphery contract as well, even though the Comptroller doesn’t check it.