DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: high
Invalid

The protocol is not compatible with Curve

Summary

The current design of Curve interface we can see in ICurve and it looks like this:

interface ICurveStableSwapNG {
function exchange(int128 i, int128 j, uint256 dx, uint256 minDy, address _receiver) external;
function price_oracle(uint256 i) external view returns (uint256);
}
interface ICurveRouterNG {
function exchange(address[11] calldata _route, uint256[5][5] calldata _swapParams, uint256 _amountIn, uint256 _minAmountOut, address[5] calldata _pools, address _receiver) external;
}

Vulnerability Details

If we check how exchange function looks on Curve router we can see it looks like this:

@external
@payable
@nonreentrant('lock')
def exchange(
_route: address[11],
_swap_params: uint256[5][5],
_amount: uint256,
_expected: uint256,
_pools: address[5]=empty(address[5]),
_receiver: address=msg.sender
) -> uint256:
"""
@notice Performs up to 5 swaps in a single transaction.
@dev Routing and swap params must be determined off-chain. This
functionality is designed for gas efficiency over ease-of-use.
@param _route Array of [initial token, pool or zap, token, pool or zap, token, ...]
The array is iterated until a pool address of 0x00, then the last
given token is transferred to `_receiver`
@param _swap_params Multidimensional array of [i, j, swap type, pool_type, n_coins] where
i is the index of input token
j is the index of output token
The swap_type should be:
1. for `exchange`,
2. for `exchange_underlying`,
3. for underlying exchange via zap: factory stable metapools with lending base pool `exchange_underlying`
and factory crypto-meta pools underlying exchange (`exchange` method in zap)
4. for coin -> LP token "exchange" (actually `add_liquidity`),
5. for lending pool underlying coin -> LP token "exchange" (actually `add_liquidity`),
6. for LP token -> coin "exchange" (actually `remove_liquidity_one_coin`)
7. for LP token -> lending or fake pool underlying coin "exchange" (actually `remove_liquidity_one_coin`)
8. for ETH <-> WETH, ETH -> stETH or ETH -> frxETH, stETH <-> wstETH, frxETH <-> sfrxETH, ETH -> wBETH
9. for SNX swaps (sUSD, sEUR, sETH, sBTC)
pool_type: 1 - stable, 2 - crypto, 3 - tricrypto, 4 - llamma
n_coins is the number of coins in pool
@param _amount The amount of input token (`_route[0]`) to be sent.
@param _expected The minimum amount received after the final swap.
@param _pools Array of pools for swaps via zap contracts. This parameter is only needed for swap_type = 3.
@param _receiver Address to transfer the final output token to.
@return Received amount of the final output token.
"""
input_token: address = _route[0]
output_token: address = empty(address)
amount: uint256 = _amount
# validate / transfer initial token
if input_token == ETH_ADDRESS:
assert msg.value == amount
else:
assert msg.value == 0
assert ERC20(input_token).transferFrom(msg.sender, self, amount, default_return_value=True)
for i in range(1, 6):
# 5 rounds of iteration to perform up to 5 swaps
swap: address = _route[i*2-1]
pool: address = _pools[i-1] # Only for Polygon meta-factories underlying swap (swap_type == 6)
output_token = _route[i*2]
params: uint256[5] = _swap_params[i-1] # i, j, swap_type, pool_type, n_coins
if not self.is_approved[input_token][swap]:
assert ERC20(input_token).approve(swap, max_value(uint256), default_return_value=True, skip_contract_check=True)
self.is_approved[input_token][swap] = True
eth_amount: uint256 = 0
if input_token == ETH_ADDRESS:
eth_amount = amount
# perform the swap according to the swap type
if params[2] == 1:
if params[3] == 1: # stable
StablePool(swap).exchange(convert(params[0], int128), convert(params[1], int128), amount, 0, value=eth_amount)
else: # crypto, tricrypto or llamma
if input_token == ETH_ADDRESS or output_token == ETH_ADDRESS:
CryptoPoolETH(swap).exchange(params[0], params[1], amount, 0, True, value=eth_amount)
else:
CryptoPool(swap).exchange(params[0], params[1], amount, 0)
elif params[2] == 2:
if params[3] == 1: # stable
StablePool(swap).exchange_underlying(convert(params[0], int128), convert(params[1], int128), amount, 0, value=eth_amount)
else: # crypto or tricrypto
CryptoPool(swap).exchange_underlying(params[0], params[1], amount, 0, value=eth_amount)
elif params[2] == 3: # SWAP IS ZAP HERE !!!
if params[3] == 1: # stable
LendingBasePoolMetaZap(swap).exchange_underlying(pool, convert(params[0], int128), convert(params[1], int128), amount, 0)
else: # crypto or tricrypto
use_eth: bool = input_token == ETH_ADDRESS or output_token == ETH_ADDRESS
CryptoMetaZap(swap).exchange(pool, params[0], params[1], amount, 0, use_eth, value=eth_amount)
elif params[2] == 4:
if params[4] == 2:
amounts: uint256[2] = [0, 0]
amounts[params[0]] = amount
StablePool2Coins(swap).add_liquidity(amounts, 0, value=eth_amount)
elif params[4] == 3:
amounts: uint256[3] = [0, 0, 0]
amounts[params[0]] = amount
StablePool3Coins(swap).add_liquidity(amounts, 0, value=eth_amount)
elif params[4] == 4:
amounts: uint256[4] = [0, 0, 0, 0]
amounts[params[0]] = amount
StablePool4Coins(swap).add_liquidity(amounts, 0, value=eth_amount)
elif params[4] == 5:
amounts: uint256[5] = [0, 0, 0, 0, 0]
amounts[params[0]] = amount
StablePool5Coins(swap).add_liquidity(amounts, 0, value=eth_amount)
elif params[2] == 5:
amounts: uint256[3] = [0, 0, 0]
amounts[params[0]] = amount
LendingStablePool3Coins(swap).add_liquidity(amounts, 0, True, value=eth_amount) # example: aave on Polygon
elif params[2] == 6:
if params[3] == 1: # stable
StablePool(swap).remove_liquidity_one_coin(amount, convert(params[1], int128), 0)
else: # crypto or tricrypto
CryptoPool(swap).remove_liquidity_one_coin(amount, params[1], 0) # example: atricrypto3 on Polygon
elif params[2] == 7:
LendingStablePool3Coins(swap).remove_liquidity_one_coin(amount, convert(params[1], int128), 0, True) # example: aave on Polygon
elif params[2] == 8:
if input_token == ETH_ADDRESS and output_token == WETH_ADDRESS:
WETH(swap).deposit(value=amount)
elif input_token == WETH_ADDRESS and output_token == ETH_ADDRESS:
WETH(swap).withdraw(amount)
elif input_token == ETH_ADDRESS and output_token == STETH_ADDRESS:
stETH(swap).submit(0x0000000000000000000000000000000000000000, value=amount)
elif input_token == ETH_ADDRESS and output_token == FRXETH_ADDRESS:
frxETHMinter(swap).submit(value=amount)
elif input_token == STETH_ADDRESS and output_token == WSTETH_ADDRESS:
wstETH(swap).wrap(amount)
elif input_token == WSTETH_ADDRESS and output_token == STETH_ADDRESS:
wstETH(swap).unwrap(amount)
elif input_token == FRXETH_ADDRESS and output_token == SFRXETH_ADDRESS:
sfrxETH(swap).deposit(amount, self)
elif input_token == SFRXETH_ADDRESS and output_token == FRXETH_ADDRESS:
sfrxETH(swap).redeem(amount, self, self)
elif input_token == ETH_ADDRESS and output_token == WBETH_ADDRESS:
wBETH(swap).deposit(0xeCb456EA5365865EbAb8a2661B0c503410e9B347, value=amount)
else:
raise "Swap type 8 is only for ETH <-> WETH, ETH -> stETH or ETH -> frxETH, stETH <-> wstETH, frxETH <-> sfrxETH, ETH -> wBETH"
elif params[2] == 9:
Synthetix(swap).exchangeAtomically(self.snx_currency_keys[input_token], amount, self.snx_currency_keys[output_token], SNX_TRACKING_CODE, 0)
else:
raise "Bad swap type"
# update the amount received
if output_token == ETH_ADDRESS:
amount = self.balance
else:
amount = ERC20(output_token).balanceOf(self)
# sanity check, if the routing data is incorrect we will have a 0 balance and that is bad
assert amount != 0, "Received nothing"
# check if this was the last swap
if i == 5 or _route[i*2+1] == empty(address):
break
# if there is another swap, the output token becomes the input for the next round
input_token = output_token
amount -= 1 # Change non-zero -> non-zero costs less gas than zero -> non-zero
assert amount >= _expected, "Slippage"
# transfer the final token to the receiver
if output_token == ETH_ADDRESS:
raw_call(_receiver, b"", value=amount)
else:
assert ERC20(output_token).transfer(_receiver, amount, default_return_value=True)
log Exchange(msg.sender, _receiver, _route, _swap_params, _pools, _amount, amount)
return amount

The issue here is that the exchange function return amount which is type of uint256 , the current design of exchange function in ICurve does not include that returned amount which make protocol not fully compatible with Curve.

Furthermore if we check the exchange function in CurveStableSwapNG we can see that it also have return value which is not included in ICurve:

@external
@nonreentrant('lock')
def exchange(
i: int128,
j: int128,
_dx: uint256,
_min_dy: uint256,
_receiver: address = msg.sender,
) -> uint256:
"""
@notice Perform an exchange between two coins
@dev Index values can be found via the `coins` public getter method
@param i Index value for the coin to send
@param j Index value of the coin to receive
@param _dx Amount of `i` being exchanged
@param _min_dy Minimum amount of `j` to receive
@param _receiver Address that receives `j`
@return Actual amount of `j` received
"""
return self._exchange(
msg.sender,
i,
j,
_dx,
_min_dy,
_receiver,
False
)

This make also the exchange function in ICurveStableSwapNG not fully compatible with Curve protocol.
Without return value protocol may not correctly handle the return value.

Impact

Protocol is not compatible with Curve

Tools Used

Manual Review

Recommendations

Make following changes in ICurve:

interface ICurveStableSwapNG {
+ function exchange(int128 i, int128 j, uint256 dx, uint256 minDy, address _receiver) external returns (uint256);
function price_oracle(uint256 i) external view returns (uint256);
}
interface ICurveRouterNG {
+ function exchange(address[11] calldata _route, uint256[5][5] calldata _swapParams, uint256 _amountIn, uint256 _minAmountOut, address[5] calldata _pools, address _receiver) external returns (uint256);
}
Updates

Appeal created

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.