Platform: Code4rena
Start Date: 05/05/2022
Pot Size: $125,000 DAI
Total HM: 17
Participants: 62
Period: 14 days
Judge: leastwood
Total Solo HM: 15
Id: 120
League: ETH
Rank: 4/62
Findings: 3
Award: $6,797.20
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: WatchPug
6389.4401 DAI - $6,389.44
function _normalizeUnderlyingTokensToDebt(address underlyingToken, uint256 amount) internal view returns (uint256) { return amount * _underlyingTokens[underlyingToken].conversionFactor; }
function repay(address underlyingToken, uint256 amount, address recipient) external override lock returns (uint256) { ... uint256 credit = _normalizeUnderlyingTokensToDebt(underlyingToken, actualAmount); // Update the recipient's debt. _updateDebt(recipient, -SafeCast.toInt256(credit)); ...
When repaying the debt with an underlyingToken
, the amount in terms of the underlyingToken
(adjusted for decimals) will always be taken in a 1:1 ratio/price for the subtrahend of the debt.
We believe this design is flawed and be exploited by arbitrageurs and eventually drives the market price of alToken to match the worst depegged underlyingToken.
Because if alToken
is trading at a higher price against the depegged underlyingToken, the arbitrageur can always mint alToken and market sell for more depegged underlyingToken and repay the debt.
Given:
$0.9
:An arbitrageur can:
100M USDC
as collateral and mint 75M alUSD
(100*0.75);75M USDT
with 67.5M alUSD
(75*0.9) and repay the debt;100M USDC
collateral;7.5M alUSD
(75-67.5) for profit.This can be repeated until the market price of alUSD drops to $0.9
, the same price as USDT.
Consider updating the repay()
function and change to market buy using the underlyingToken to alToken and then burn()
the alToken to reduce debt.
#0 - 0xfoobar
2022-05-22T18:52:59Z
Sponsor acknowledged
This is a core design choice underlying the Alchemix system. Like-kind collateral and debt assumes that assets will hold their relationship. The onus lies on governance to choose safe collateral assets.
#1 - 0xleastwood
2022-06-08T15:08:59Z
Before I acknowledge the sponsor's comment, I just want to note that the same issue has been duplicated by the warden in #162 and #163. I believe it would be fair to first close these issues in favour of keeping this open.
#2 - 0xleastwood
2022-06-08T16:05:25Z
I agree with what was raised by the warden but disagree with the associated severity. Because user's assets are tied to the underlying collateral, it makes sense that the a depegged token would be reflected in the price of alToken
. As a result, arbitrageurs are free to profit of this by driving the price of alToken
down to its true value.
I consider this to be medium risk because of an unlikely assumption made when considering the likelihood of a depeg event. Assets are not at direct risk, but value can be leaked under certain assumptions.
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x4non, 0xDjango, 0xNazgul, 0xkatana, 0xsomeone, AuditsAreUS, BouSalman, BowTiedWardens, Cityscape, Funen, GimelSec, Hawkeye, JC, MaratCerby, MiloTruck, Picodes, Ruhum, TerrierLover, WatchPug, Waze, bobirichman, catchup, cccz, cryptphi, csanuragjain, delfin454000, ellahi, fatherOfBlocks, hake, horsefacts, hyh, jayjonah8, joestakey, kebabsec, kenta, mics, oyc_109, robee, samruna, shenwilly, sikorico, simon135, throttle, tintin
206.5302 DAI - $206.53
The new adapter should be checked for a matching underlyingToken
.
function setTokenAdapter(address yieldToken, address adapter) external override { _onlyAdmin(); _checkState(yieldToken == ITokenAdapter(adapter).token()); _checkSupportedYieldToken(yieldToken); _yieldTokens[yieldToken].adapter = adapter; TokenUtils.safeApprove(yieldToken, adapter, type(uint256).max); TokenUtils.safeApprove(ITokenAdapter(adapter).underlyingToken(), adapter, type(uint256).max); emit TokenAdapterUpdated(yieldToken, adapter); }
function setTokenAdapter(address yieldToken, address adapter) external override { _onlyAdmin(); _checkState(yieldToken == ITokenAdapter(adapter).token()); _checkState(_yieldTokens[yieldToken].underlyingToken == ITokenAdapter(adapter).underlyingToken()); _checkSupportedYieldToken(yieldToken); _yieldTokens[yieldToken].adapter = adapter; TokenUtils.safeApprove(yieldToken, adapter, type(uint256).max); TokenUtils.safeApprove(ITokenAdapter(adapter).underlyingToken(), adapter, type(uint256).max); emit TokenAdapterUpdated(yieldToken, adapter); }
function setTokenAdapter(address yieldToken, address adapter) external override { _onlyAdmin(); _checkState(yieldToken == ITokenAdapter(adapter).token()); _checkSupportedYieldToken(yieldToken); _yieldTokens[yieldToken].adapter = adapter; TokenUtils.safeApprove(yieldToken, adapter, type(uint256).max); TokenUtils.safeApprove(ITokenAdapter(adapter).underlyingToken(), adapter, type(uint256).max); emit TokenAdapterUpdated(yieldToken, adapter); }
function setTokenAdapter(address yieldToken, address adapter) external override { _onlyAdmin(); _checkState(yieldToken == ITokenAdapter(adapter).token()); _checkSupportedYieldToken(yieldToken); address oldAdapter = _yieldTokens[yieldToken].adapter; address underlyingToken = ITokenAdapter(adapter).underlyingToken(); TokenUtils.safeApprove(yieldToken, oldAdapter, 0); TokenUtils.safeApprove(underlyingToken, oldAdapter, 0); _yieldTokens[yieldToken].adapter = adapter; TokenUtils.safeApprove(yieldToken, adapter, type(uint256).max); TokenUtils.safeApprove(underlyingToken, adapter, type(uint256).max); emit TokenAdapterUpdated(yieldToken, adapter); }
#0 - 0xleastwood
2022-06-11T16:12:33Z
These are useful and seem to be unique.
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0x1f8b, 0x4non, 0xDjango, 0xNazgul, 0xf15ers, 0xkatana, 0xsomeone, AlleyCat, BowTiedWardens, Cityscape, Fitraldys, Funen, GimelSec, Hawkeye, JC, MaratCerby, MiloTruck, Randyyy, TerrierLover, Tomio, UnusualTurtle, WatchPug, Waze, _Adam, augustg, bobirichman, catchup, csanuragjain, ellahi, fatherOfBlocks, hake, hansfriese, horsefacts, ignacio, joestakey, kenta, mics, oyc_109, robee, samruna, sashik_eth, sikorico, simon135, throttle
201.2269 DAI - $201.23
[S]: Suggested optimation, save a decent amount of gas without compromising readability;
[M]: Minor optimation, the amount of gas saved is minor, change when you see fit;
[N]: Non-preferred, the amount of gas saved is at cost of readability, only apply when gas saving is a top priority.
AlchemistV2.sol#_calculateUnrealizedActiveBalance()
Implementation can be simpler and save some gasif (activeBalance == 0) { return activeBalance; }
Change to:
if (activeBalance == 0) { return 0; }
YieldTokenParams storage yieldTokenParams = _yieldTokens[yieldToken];
Account storage account = _accounts[owner];
Limiters.LinearGrowthLimiter storage limiter = _repayLimiters[underlyingToken];
Sets.AddressSet storage depositedTokens = _accounts[owner].depositedTokens;
YieldTokenParams storage yieldTokenParams = _yieldTokens[yieldToken];
...
The gas cost of storage reads (SLOAD
) is significant.
Copying to memory can save some gas.
Reading array length at each iteration of the loop takes 6 gas (3 for mload and 3 to place memory_offset) in the stack.
Caching the array length in the stack saves around 3 gas per iteration.
Instances include:
AlchemistV2.sol
CrossChainCanonicalBase.sol
StakingPools.sol
TransmuterBuffer.sol
...
uint
variables to 0
is redundantSetting uint
variables to 0
is redundant as they default to 0
.
Instances include:
uint256 totalValue = 0;
uint256 total = 0;
uint256 normalizedTotal = 0;
uint256 want = 0;
uint256 exchangeDelta = 0;
uint256 public forcedSlippage = 0;
...
uint256 public min = 9500;
Some storage variables include min
will not never be changed and they should not be.
Changing them to constant
can save gas.
Change to:
uint256 public constant min = 9500;
++i
is more efficient than i++
Using ++i
is more gas efficient than i++
, especially in for loops.
For example:
hhttps://github.com/code-423n4/2022-05-alchemix/blob/de65c34c7b6e4e94662bf508e214dcbf327984f4/contracts-full/AlchemistV2.sol#L990
for (uint256 i = 0; i < depositedTokens.values.length; i++)
for (uint256 i = 0; i < _bridgeTokens.length; i++)
for (uint256 i = 0; i < NUM_META_COINS; i++)
for (uint256 i = 0; i < 256; i++)
for (uint256 i = 0; i < _yieldTokens[underlyingToken].length; i++)
...
Unused local variables in contracts increase contract size and gas usage at deployment.
Instances include:
function _alchemistWithdraw(address token, uint256 amountUnderlying) internal { uint8 decimals = TokenUtils.expectDecimals(token); uint256 pricePerShare = IAlchemistV2(alchemist).getUnderlyingTokensPerShare(token); uint256 wantShares = amountUnderlying * 10**decimals / pricePerShare; (uint256 availableShares, uint256 lastAccruedWeight) = IAlchemistV2(alchemist).positions(address(this), token); if (wantShares > availableShares) { wantShares = availableShares; } // Allow 1% slippage uint256 minimumAmountOut = amountUnderlying - amountUnderlying * 100 / 10000; if (wantShares > 0) { IAlchemistV2(alchemist).withdrawUnderlying(token, wantShares, address(this), minimumAmountOut); } }
lastAccruedWeight
is unused.
function reApprove() public { bool success = alcx.approve(address(pools), type(uint).max); }
success
is unused.
IERC20 public alcx = IERC20(0xdBdb4d16EdA451D0503b854CF79D55697F90c8DF);
Considering that alcx
will never change, changing it to immutable variable instead of storage variable can save gas.
For the arithmetic operations that will never over/underflow, using the unchecked directive (Solidity v0.8 has default overflow/underflow checks) can save some gas from the unnecessary internal over/underflow checks.
For example:
if (cliff >= totalCliffs) return 0; uint256 reduction = totalCliffs - cliff;
totalCliffs - cliff
will never underflow.
if (cliff >= totalCliffs) return 0; unchecked{ uint256 reduction = totalCliffs - cliff; }
// Allow 1% slippage uint256 minimumAmountOut = amountUnderlying - amountUnderlying * 100 / 10000;
// Allow 1% slippage uint256 minimumAmountOut = amountUnderlying * 9900 / 10000;
exchanged > 0
can save gasif (state.unexchangedBalance > 0) { uint256 exchanged = LiquidityMath.calculateProduct( state.unexchangedBalance, ticks.getWeight(cache.occupiedTick, cache.currentTick) ); state.totalUnexchanged -= exchanged; state.unexchangedBalance -= exchanged; state.exchangedBalance += exchanged; }
Change to:
if (state.unexchangedBalance > 0) { uint256 exchanged = LiquidityMath.calculateProduct( state.unexchangedBalance, ticks.getWeight(cache.occupiedTick, cache.currentTick) ); if (exchanged > 0){ state.totalUnexchanged -= exchanged; state.unexchangedBalance -= exchanged; state.exchangedBalance += exchanged; } }
function _getExchangedBalance(address owner) internal view returns (uint256 exchangedBalance) { Account storage account = accounts[owner]; if (account.occupiedTick <= satisfiedTick) { exchangedBalance = account.exchangedBalance; exchangedBalance += account.unexchangedBalance; return exchangedBalance; } exchangedBalance = account.exchangedBalance; uint256 exchanged = LiquidityMath.calculateProduct( account.unexchangedBalance, ticks.getWeight(account.occupiedTick, ticks.position) ); exchangedBalance += exchanged; return exchangedBalance; }
At L572, returning exchangedBalance
is redundant as named returns will be automatically handled.
#0 - 0xfoobar
2022-05-30T07:00:38Z
Good point about return 0
instead of return activeBalance