From 7f9a995e8a12a3919222446e72d88ed37d051ba8 Mon Sep 17 00:00:00 2001 From: Domenico <118726956+domenicodev@users.noreply.github.com> Date: Fri, 27 Jun 2025 12:03:32 +0200 Subject: [PATCH] Curve Adapter (Stable and Crypto Swap) (#29) * feat: Initial manifest structure * feat: Adapter initial development and price function implementation * feat: Implemented getPoolIDs * feat: Implemented getTokens * feat: Implemented getCapabilities * feat: Implemented getLimits * feat: Implemented swap * feat: Created Adapter version for CurveV2 pools uint256 * fix: Review Fixes * chore: Formatted code and code adjustments * feat: Separated contracts and finished tests for Curve Exchange(Crypto and StableSwap) * feat: Adjusted naming and formatted tests code * chore: Initial merging of swaps * merge CurveCryptoSwapAdapter and CurveStableSwapAdapter into CurveAdapter * Fix and Test: fixes on CurveAdapter.sol and created CurveAdapter.t.sol * review and fixes * removed unused test and parameters * chore: Initial MetaPool underlying swap and ETH native implementation * chore: expanded swap and sell functions to support seap for underlying tokens and LPS * chore: expanded functionalities of CurveAdapter and fixed getTokens function * fix: fixed registry.is_meta * chore: fixes * chore: extended adapter and implementing tests for new pools * chore: updated Adapter, implementing final tests * fixing eth transfer * fix and tests: fixed adapter and finishing tests implementation * using adapter with try catch * feat: Final fixes for pools support, using try-catch * chore: Removed chunk files * chore: Formatted code and removed unused condition * fix: Fixed calculatedAmount in sell function when receiveToken=ETH * chore: Adjusted ETH pools check * corrected metaregistry address * fix: fixed int128 conversion to unit256 * feat: Removed registry from adapter * feat: Implemented price() * fix: Propeller review fixes * review fixes * fix: Final fixes for custom custom int128 pools * chore: Removed unused test * fix: Fixed price error in custom pools * feat: Improved isInt128Pool function to support any coin * fix: Fixed price for custom pools using ETH balance when token0 is WETH * fix: Fixed price function and added AdapterTest support in test * fix: Fixed divisions in getPriceAt * feat: Added secondary ETH pool support, e.g. stETH * refactor(curve-adapter): Avoid calling WETH contract if possible. (#65) * refactor(curve-adapter): Avoid calling WETH contract if possible. This PR aims to use native ETH instead of WETH when possible, this is to avoid having to index WETH contract. * style(adapter): apply `forge fmt` * refactor(curve): change limit factor to 2 --------- Co-authored-by: Florian Pellissier <111426680+flopell@users.noreply.github.com> * refactor: fix Curve Adapter test after rebase * fix: remove PriceFunction capability for Curve This was wrongly implemented: it return the price for a quote and not the marginal price after the swap. * refactor: improve adapter test and fix failing test for etherfi --------- Co-authored-by: domenicodev Co-authored-by: mp-web3 Co-authored-by: Zizou <111426680+zizou0x@users.noreply.github.com> Co-authored-by: Florian Pellissier <111426680+flopell@users.noreply.github.com> Co-authored-by: Diana Carvalho --- evm/src/curve/CurveAdapter.sol | 606 +++++++++++++++++++++++++++++++++ evm/src/curve/manifest.yaml | 35 ++ evm/test/AdapterTest.sol | 9 +- evm/test/CurveAdapter.t.sol | 420 +++++++++++++++++++++++ evm/test/EtherfiAdapter.t.sol | 14 +- 5 files changed, 1077 insertions(+), 7 deletions(-) create mode 100644 evm/src/curve/CurveAdapter.sol create mode 100644 evm/src/curve/manifest.yaml create mode 100644 evm/test/CurveAdapter.t.sol diff --git a/evm/src/curve/CurveAdapter.sol b/evm/src/curve/CurveAdapter.sol new file mode 100644 index 0000000..bbacf87 --- /dev/null +++ b/evm/src/curve/CurveAdapter.sol @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma experimental ABIEncoderV2; +pragma solidity ^0.8.13; + +import {ISwapAdapter} from "src/interfaces/ISwapAdapter.sol"; +import { + IERC20, + SafeERC20 +} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import "src/libraries/FractionMath.sol"; + +/// @dev custom RESERVE_LIMIT_FACTOR for limits for this adapter(underestimate) +uint256 constant RESERVE_LIMIT_FACTOR = 2; + +/// @title Curve Finance Adapter +/// @dev This contract supports both CryptoSwap and StableSwap Curve pools +contract CurveAdapter is ISwapAdapter { + using SafeERC20 for IERC20; + using FractionMath for Fraction; + + struct SellParamsCache { + address poolAddress; // address of the pool to swap in + address sellToken; // address of the token to sell + address buyToken; // address of the token to buy + int128 sellTokenIndex; // index of the token being sold + int128 buyTokenIndex; // index of the token being bought + uint256 specifiedAmount; // amount to trade + bool isInt128Pool; // pool is int128 + } + + struct PoolCoins { + address[8] addresses; + uint256 coinsLength; + } + + uint256 constant PRECISION = 10 ** 5; + + address constant WETH_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + constructor() {} + + /// @dev enable receive as this contract supports ETH + receive() external payable {} + + /// @inheritdoc ISwapAdapter + function price( + bytes32 poolId, + address sellToken, + address buyToken, + uint256[] memory specifiedAmounts + ) external override returns (Fraction[] memory prices) { + revert NotImplemented("CurveAdapter.price"); + } + + /// @inheritdoc ISwapAdapter + function swap( + bytes32 poolId, + address sellToken, + address buyToken, + OrderSide side, + uint256 specifiedAmount + ) external override returns (Trade memory trade) { + if (specifiedAmount == 0) { + return trade; + } + + SellParamsCache memory sellParams; + { + sellParams.poolAddress = address(bytes20(poolId)); + sellParams.sellToken = sellToken; + sellParams.buyToken = buyToken; + sellParams.specifiedAmount = specifiedAmount; + + bool isEthPool; // pool is native ETH pool + PoolCoins memory coins = getCoins(sellParams.poolAddress); + sellParams.isInt128Pool = + isInt128Pool(sellParams.poolAddress, coins); + + /// @dev Support for Native ETH pools, ETH pools cannot be Meta + /// therefore we can directly access coins without using underlying + if (sellToken == address(0)) { + for (uint256 i = 0; i < coins.coinsLength; i++) { + if ( + coins.addresses[i] == ETH_ADDRESS + || coins.addresses[i] == WETH_ADDRESS + ) { + sellParams.sellToken = ETH_ADDRESS; + if (coins.addresses[i] == ETH_ADDRESS) { + isEthPool = true; + } + break; + } + } + } else if (buyToken == address(0)) { + for (uint256 i = 0; i < coins.coinsLength; i++) { + if ( + coins.addresses[i] == ETH_ADDRESS + || coins.addresses[i] == WETH_ADDRESS + ) { + sellParams.buyToken = ETH_ADDRESS; + if (coins.addresses[i] == ETH_ADDRESS) { + isEthPool = true; + } + break; + } + } + } + + (sellParams.sellTokenIndex, sellParams.buyTokenIndex) = + getCoinsIndices( + sellParams.sellToken, sellParams.buyToken, coins, isEthPool + ); + } + + uint256 gasBefore = gasleft(); + + if (side == OrderSide.Sell) { + trade.calculatedAmount = sell(sellParams); + } else { + revert Unavailable( + "OrderSide.Buy is not available for this adapter" + ); + } + + trade.gasUsed = gasBefore - gasleft(); + trade.price = getPriceAt(sellParams, true); + } + + /// @inheritdoc ISwapAdapter + function getLimits(bytes32 poolId, address sellToken, address buyToken) + external + view + override + returns (uint256[] memory limits) + { + address poolAddress = address(bytes20(poolId)); + ICurveStableSwapPool pool = ICurveStableSwapPool(poolAddress); + address sellToken_ = sellToken; + address buyToken_ = buyToken; + bool isEthPool; + PoolCoins memory coins = getCoins(poolAddress); + + /// @dev Support for Native ETH pools, ETH pools cannot be Meta + /// therefore we can directly access coins without using underlying + if (sellToken == address(0)) { + for (uint256 i = 0; i < coins.coinsLength; i++) { + if ( + coins.addresses[i] == ETH_ADDRESS + || coins.addresses[i] == WETH_ADDRESS + ) { + sellToken_ = ETH_ADDRESS; + if (coins.addresses[i] == ETH_ADDRESS) { + isEthPool = true; + } + break; + } + } + } else if (buyToken == address(0)) { + for (uint256 i = 0; i < coins.coinsLength; i++) { + if ( + coins.addresses[i] == ETH_ADDRESS + || coins.addresses[i] == WETH_ADDRESS + ) { + buyToken_ = ETH_ADDRESS; + if (coins.addresses[i] == ETH_ADDRESS) { + isEthPool = true; + } + break; + } + } + } + + (int128 sellTokenIndex, int128 buyTokenIndex) = + getCoinsIndices(sellToken_, buyToken_, coins, isEthPool); + + limits = new uint256[](2); + uint256 sellTokenIndexUint = uint256(uint128(sellTokenIndex)); + uint256 buyTokenIndexUint = uint256(uint128(buyTokenIndex)); + try pool.balances(sellTokenIndexUint) returns (uint256 bal) { + limits[0] = bal / RESERVE_LIMIT_FACTOR; + limits[1] = pool.balances(buyTokenIndexUint) / RESERVE_LIMIT_FACTOR; + } catch { + limits[0] = ICurveCustomInt128Pool(poolAddress).balances( + sellTokenIndex + ) / RESERVE_LIMIT_FACTOR; + limits[1] = ICurveCustomInt128Pool(poolAddress).balances( + buyTokenIndex + ) / RESERVE_LIMIT_FACTOR; + } + } + + /// @inheritdoc ISwapAdapter + function getCapabilities(bytes32, address, address) + external + pure + override + returns (Capability[] memory capabilities) + { + capabilities = new Capability[](2); + capabilities[0] = Capability.SellOrder; + } + + /// @inheritdoc ISwapAdapter + function getTokens(bytes32 poolId) + external + view + override + returns (address[] memory tokens) + { + PoolCoins memory coins = getCoins(address(bytes20(poolId))); + address[] memory tokensTmp = new address[](coins.coinsLength); + bool containsETH; + for (uint256 j = 0; j < coins.coinsLength; j++) { + if (coins.addresses[j] == WETH_ADDRESS) { + containsETH = true; + } + if (coins.addresses[j] == ETH_ADDRESS) { + continue; + } + tokensTmp[j] = coins.addresses[j]; + } + + if (containsETH) { + tokens = new address[](coins.coinsLength + 1); + for (uint256 k = 0; k < coins.coinsLength; k++) { + tokens[k] = tokensTmp[k]; + } + tokens[coins.coinsLength] = address(0); + } else { + tokens = tokensTmp; + } + } + + function getPoolIds(uint256, uint256) + external + pure + override + returns (bytes32[] memory) + { + revert NotImplemented("CurveAdapter.getPoolIds"); + } + + /// @notice Calculates pool prices for specified amounts + /// @param sellParams Params for the price(see: struct SellParamsCache). + /// @param useGenericAmount Determine if a amount used to determine the + /// price is a small amount of the reserve(true) or + /// sellParams.specifiedAmount(false) + /// @return (Fraction) price as a fraction corresponding to the provided + /// amount. + function getPriceAt( + SellParamsCache memory sellParams, + bool useGenericAmount + ) internal view returns (Fraction memory) { + uint256 amountIn; + uint256 sellTokenIndexUint = uint256(uint128(sellParams.sellTokenIndex)); + uint256 buyTokenIndexUint = uint256(uint128(sellParams.buyTokenIndex)); + if (sellParams.isInt128Pool) { + try ICurveStableSwapPool(sellParams.poolAddress).balances( + sellTokenIndexUint + ) returns (uint256 bal) { + amountIn = useGenericAmount + ? (bal / PRECISION) + : sellParams.specifiedAmount; + } catch { + amountIn = useGenericAmount + ? ( + ICurveCustomInt128Pool(sellParams.poolAddress).balances( + sellParams.sellTokenIndex + ) / PRECISION + ) + : sellParams.specifiedAmount; + } + + return Fraction( + ICurveStableSwapPool(sellParams.poolAddress).get_dy( + sellParams.sellTokenIndex, + sellParams.buyTokenIndex, + amountIn + ), + amountIn + ); + } else { + amountIn = useGenericAmount + ? ( + ICurveCryptoSwapPool(sellParams.poolAddress).balances( + sellTokenIndexUint + ) / PRECISION + ) + : sellParams.specifiedAmount; + + return Fraction( + ICurveCryptoSwapPool(sellParams.poolAddress).get_dy( + sellTokenIndexUint, buyTokenIndexUint, amountIn + ), + amountIn + ); + } + } + + /// @notice Executes a sell order on a given pool. + /// @dev Only metapools available (with LP token as counter pair) are + /// Stable, + /// but after some research we've found that Curve deployed some pools that + /// are Crypto and use the int128 interface, therefore we optimistically + /// support them too. + /// @param sellParams Params for the trade(see: struct SellParamsCache). + /// @return calculatedAmount The amount of tokens received. + function sell(SellParamsCache memory sellParams) + internal + returns (uint256 calculatedAmount) + { + IERC20 buyToken = IERC20(sellParams.buyToken); + IERC20 sellToken = IERC20(sellParams.sellToken); + uint256 nativeTokenBalBefore = address(this).balance; + uint256 buyTokenBalBefore = (sellParams.buyToken == ETH_ADDRESS) + ? address(this).balance + : buyToken.balanceOf(address(this)); + + if (sellParams.isInt128Pool) { + if (sellParams.sellToken == ETH_ADDRESS) { + // ETH Pool + ICurveStableSwapPoolEth(sellParams.poolAddress).exchange{ + value: sellParams.specifiedAmount + }( + sellParams.sellTokenIndex, + sellParams.buyTokenIndex, + sellParams.specifiedAmount, + 0 + ); + } else { + sellToken.safeTransferFrom( + msg.sender, address(this), sellParams.specifiedAmount + ); + sellToken.safeIncreaseAllowance( + sellParams.poolAddress, sellParams.specifiedAmount + ); + ICurveStableSwapPool(sellParams.poolAddress).exchange( + sellParams.sellTokenIndex, + sellParams.buyTokenIndex, + sellParams.specifiedAmount, + 0 + ); + } + } else { + uint256 sellTokenIndexUint = + uint256(uint128(sellParams.sellTokenIndex)); + uint256 buyTokenIndexUint = + uint256(uint128(sellParams.buyTokenIndex)); + if (sellParams.sellToken == ETH_ADDRESS) { + ICurveCryptoSwapPoolEth(sellParams.poolAddress).exchange{ + value: sellParams.specifiedAmount + }( + sellTokenIndexUint, + buyTokenIndexUint, + sellParams.specifiedAmount, + 0, + true, + address(this) + ); + } else { + sellToken.safeTransferFrom( + msg.sender, address(this), sellParams.specifiedAmount + ); + sellToken.safeIncreaseAllowance( + sellParams.poolAddress, sellParams.specifiedAmount + ); + // @dev if available try to swap with use_eth set to true. + try ICurveCryptoSwapPoolEth(sellParams.poolAddress).exchange( + sellTokenIndexUint, + buyTokenIndexUint, + sellParams.specifiedAmount, + 0, + true, + address(this) + ) { + // @dev we can't use catch here because some Curve pool have + // a fallback function implemented. So this call succeed + // without doing anything. + uint256 maybeNativeReceived = + address(this).balance - nativeTokenBalBefore; + if (maybeNativeReceived > 0) { + calculatedAmount = maybeNativeReceived; // ETH received + (bool sent,) = address(msg.sender).call{ + value: maybeNativeReceived + }(""); + require(sent, "Eth transfer failed"); + } else { + calculatedAmount = buyToken.balanceOf(address(this)) + - buyTokenBalBefore; + buyToken.safeTransfer( + address(msg.sender), calculatedAmount + ); + } + if (calculatedAmount > 0) { + return calculatedAmount; + } + } catch {} + // @dev else use the generic interface. + ICurveCryptoSwapPool(sellParams.poolAddress).exchange( + sellTokenIndexUint, + buyTokenIndexUint, + sellParams.specifiedAmount, + 0 + ); + } + } + + if (sellParams.buyToken == ETH_ADDRESS) { + calculatedAmount = address(this).balance - buyTokenBalBefore; + (bool sent,) = address(msg.sender).call{value: calculatedAmount}(""); + require(sent, "Eth transfer failed"); + } else { + calculatedAmount = + buyToken.balanceOf(address(this)) - buyTokenBalBefore; + buyToken.safeTransfer(address(msg.sender), calculatedAmount); + } + } + + /// @dev Check whether a pool supports int128 inputs or uint256(excluded + /// custom) + /// @param poolAddress address of the pool + /// @param coins list of coin addresses in the pool + function isInt128Pool(address poolAddress, PoolCoins memory coins) + internal + view + returns (bool) + { + // @dev We avoid using ETH/WETH as a token here because it might create + // a requirement to index WETH when it's not needed. + uint256 sampleTokenIndex = ( + coins.addresses[0] == ETH_ADDRESS + || coins.addresses[0] == WETH_ADDRESS + ) ? 1 : 0; + uint256 sampleAmount = + IERC20(coins.addresses[sampleTokenIndex]).balanceOf(poolAddress); + + try ICurveCryptoSwapPool(poolAddress).get_dy( + sampleTokenIndex == 0 ? 0 : 1, + sampleTokenIndex == 0 ? 1 : 0, + sampleAmount / 10 + ) returns (uint256) { + return false; + } catch { + return true; + } + } + + /// @dev Check whether a pool is a custom int128 pool(balances, coins, ... + /// accept int128 as input) + /// @param poolAddress address of the pool + function isCustomInt128Pool(address poolAddress) + internal + view + returns (bool) + { + try ICurveStableSwapPool(poolAddress).balances(0) returns (uint256) { + return false; + } catch { + return true; + } + } + + /// @notice Get coins inside a pool + /// @param poolAddress The address of the pool + function getCoins(address poolAddress) + internal + view + returns (PoolCoins memory output) + { + uint256 len; + + /// @dev as of registry, max addresses that can be included in a pool is + /// always 8, therefore we limit the loop to it. + if (!isCustomInt128Pool(poolAddress)) { + // Pool with coins(uint256) + for (len; len < 8; len++) { + try ICurveStableSwapPool(poolAddress).coins(len) returns ( + address coin + ) { + output.addresses[len] = coin; + output.coinsLength++; + } catch { + // Pool has no coins, or the last coin has been found + break; + } + } + } else { + for (len; len < 8; len++) { + // Pool supports coins(int128) + try ICurveCustomInt128Pool(poolAddress).coins( + int128(uint128(len)) + ) returns (address coin) { + output.addresses[len] = coin; + output.coinsLength++; + } catch { + // Pool has no coins, or the last coin has been found + break; + } + } + } + } + + /// @notice Get indices of coins to swap + /// @dev If the pool is meta the registry.get_coin_indices includes the + /// underlying addresses (appended to the array from index 1 to length-1) + /// @param sellToken The token being sold + /// @param buyToken The token being bought + /// @param coins output of getCoins() + /// @param isEthPool determine if pool has native ETH inside + function getCoinsIndices( + address sellToken, + address buyToken, + PoolCoins memory coins, + bool isEthPool + ) internal pure returns (int128 sellTokenIndex, int128 buyTokenIndex) { + address sellToken_ = sellToken; + address buyToken_ = buyToken; + if (sellToken == ETH_ADDRESS && !isEthPool) { + sellToken_ = WETH_ADDRESS; + } + if (buyToken == ETH_ADDRESS && !isEthPool) { + buyToken_ = WETH_ADDRESS; + } + for (uint256 i; i < coins.coinsLength; i++) { + if (coins.addresses[i] == sellToken_) { + sellTokenIndex = int128(uint128(i)); + } else if (coins.addresses[i] == buyToken_) { + buyTokenIndex = int128(uint128(i)); + } + } + } +} + +/// @dev Wrapped ported version of Curve Plain Pool to Solidity +/// For params informations see: +/// https://docs.curve.fi/cryptoswap-exchange/cryptoswap/pools/crypto-pool/ +interface ICurveCryptoSwapPool { + function get_dy(uint256 i, uint256 j, uint256 dx) + external + view + returns (uint256); + + function exchange(uint256 i, uint256 j, uint256 dx, uint256 min_dy) + external + payable; + + function balances(uint256 arg0) external view returns (uint256); + + function fee() external view returns (uint256); +} + +interface ICurveCryptoSwapPoolEth is ICurveCryptoSwapPool { + function exchange( + uint256 i, + uint256 j, + uint256 dx, + uint256 min_dy, + bool use_eth, + address receiver + ) external payable; +} + +/// @dev Wrapped ported version of Curve Plain Pool to Solidity +/// For params informations see: +/// https://docs.curve.fi/stableswap-exchange/stableswap/pools/plain_pools/ +interface ICurveStableSwapPool { + function get_dy(int128 i, int128 j, uint256 dx) + external + view + returns (uint256); + + function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) + external; + + function balances(uint256 arg0) external view returns (uint256); + + function fee() external view returns (uint256); + + function coins(uint256 i) external view returns (address); +} + +interface ICurveStableSwapPoolEth { + function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) + external + payable; +} + +/// @dev TODO future implementation, not used at the moment since StableSwap +/// Meta Pools are not supported yet +interface ICurveStableSwapMetaPool is ICurveStableSwapPool { + function get_dy_underlying(int128 i, int128 j, uint256 dx) + external + view + returns (uint256); + + function exchange_underlying(int128 i, int128 j, uint256 dx, uint256 min_dy) + external + returns (uint256); +} + +interface ICurveCustomInt128Pool { + function coins(int128 arg0) external view returns (address); + function balances(int128 arg0) external view returns (uint256); +} diff --git a/evm/src/curve/manifest.yaml b/evm/src/curve/manifest.yaml new file mode 100644 index 0000000..ef612d2 --- /dev/null +++ b/evm/src/curve/manifest.yaml @@ -0,0 +1,35 @@ +# information about the author helps us reach out in case of issues. +author: + name: shadowycoders.dev + email: hello@shadowycreators.com + +# Protocol Constants +constants: + protocol_gas: 30000 + # minimum capabilities we can expect, individual pools may extend these + capabilities: + - SellSide + - PriceFunction + +# The file containing the adapter contract +contract: CurveAdapter.sol + +# Deployment instances used to generate chain specific bytecode. +instances: + - chain: + name: mainnet + id: 1 + arguments: + - none + +# Specify some automatic test cases in case getPoolIds and +# getTokens are not implemented. +tests: + instances: + - pool_id: "0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7" + sell_token: "0xdAC17F958D2ee523a2206206994597C13D831ec7" + buy_token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + block: 19719570 + chain: + id: 1 + name: mainnet diff --git a/evm/test/AdapterTest.sol b/evm/test/AdapterTest.sol index 76950ea..22e491e 100644 --- a/evm/test/AdapterTest.sol +++ b/evm/test/AdapterTest.sol @@ -6,9 +6,11 @@ import "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; import {ISwapAdapter} from "src/interfaces/ISwapAdapter.sol"; import "src/interfaces/ISwapAdapterTypes.sol"; import "src/libraries/FractionMath.sol"; +import "src/libraries/EfficientERC20.sol"; contract AdapterTest is Test, ISwapAdapterTypes { using FractionMath for Fraction; + using EfficientERC20 for IERC20; uint256 constant pricePrecision = 10e24; string[] public stringPctgs = ["0%", "0.1%", "50%", "100%"]; @@ -29,8 +31,8 @@ contract AdapterTest is Test, ISwapAdapterTypes { ); for (uint256 i = 0; i < poolIds.length; i++) { address[] memory tokens = adapter.getTokens(poolIds[i]); - IERC20(tokens[0]).approve(address(adapter), type(uint256).max); - IERC20(tokens[1]).approve(address(adapter), type(uint256).max); + IERC20(tokens[0]).forceApprove(address(adapter), type(uint256).max); + IERC20(tokens[1]).forceApprove(address(adapter), type(uint256).max); testPricesForPair( adapter, poolIds[i], tokens[0], tokens[1], hasPriceImpact @@ -68,6 +70,9 @@ contract AdapterTest is Test, ISwapAdapterTypes { ); uint256[] memory amounts = calculateTestAmounts(sellLimit, hasMarginalPrices); + + // TODO: What if the price function is not available? Do we still want + // to run this test? Fraction[] memory prices = adapter.price(poolId, tokenIn, tokenOut, amounts); assertGt( diff --git a/evm/test/CurveAdapter.t.sol b/evm/test/CurveAdapter.t.sol new file mode 100644 index 0000000..9a48fb2 --- /dev/null +++ b/evm/test/CurveAdapter.t.sol @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +import "./AdapterTest.sol"; +import "src/curve/CurveAdapter.sol"; + +contract CurveAdapterTest is Test, ISwapAdapterTypes, AdapterTest { + using FractionMath for Fraction; + + CurveAdapter adapter; + + // tokens + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant ETH = address(0); + address constant WBETH = 0xa2E3356610840701BDf5611a53974510Ae27E2e1; + address constant MIM = 0x99D8a9C45b2ecA8864373A26D1459e3Dff1e17F3; + address constant THREE_CRV_TOKEN = + 0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490; + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address constant stETH = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + + // pools + address constant STABLE_POOL = 0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7; + address constant CRYPTO_POOL = 0x80466c64868E1ab14a1Ddf27A676C3fcBE638Fe5; + address constant STABLE_META_POOL = + 0x5a6A4D54456819380173272A5E8E9B9904BdF41B; + address constant ETH_POOL = 0xBfAb6FA95E0091ed66058ad493189D2cB29385E6; + address constant STETH_POOL = 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022; + address[] ADDITIONAL_POOLS_FOR_TESTING; + + uint256 constant TEST_ITERATIONS = 100; + IwstETH constant wstETH = + IwstETH(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0); + + function setUp() public { + uint256 forkBlock = 20234346; + vm.createSelectFork(vm.rpcUrl("mainnet"), forkBlock); + adapter = new CurveAdapter(); + + // Additional pools that include custom Int128 pools + ADDITIONAL_POOLS_FOR_TESTING = [ + 0xEcd5e75AFb02eFa118AF914515D6521aaBd189F1, + 0xEd279fDD11cA84bEef15AF5D39BB4d4bEE23F0cA, + 0x43b4FdFD4Ff969587185cDB6f0BD875c5Fc83f8c, + 0x9EfE1A1Cbd6Ca51Ee8319AFc4573d253C3B732af, + 0x4807862AA8b2bF68830e4C8dc86D0e9A998e085a, + 0xd632f22692FaC7611d2AA1C0D552930D43CAEd3B, + 0xA5407eAE9Ba41422680e2e00537571bcC53efBfD, + 0x5a6A4D54456819380173272A5E8E9B9904BdF41B, + 0x3211C6cBeF1429da3D0d58494938299C92Ad5860, + 0xDB6925eA42897ca786a045B252D95aA7370f44b4, + 0xf861483fa7E511fbc37487D91B6FAa803aF5d37c, + 0x1e098B32944292969fB58c85bDC85545DA397117, + 0xe0e970a99bc4F53804D8145beBBc7eBc9422Ba7F, + 0x6bfE880Ed1d639bF80167b93cc9c56a39C1Ba2dC, + 0xBDFAe7D2cF2E69E27b75a5287ECD3808F62B5a76, + 0xfB8814D005C5f32874391e888da6eB2fE7a27902, + 0x0f3159811670c117c372428D4E69AC32325e4D0F, + 0x4eBdF703948ddCEA3B11f675B4D1Fba9d2414A14, + 0x0E9B5B092caD6F1c5E6bc7f89Ffe1abb5c95F1C2, + 0x21410232B484136404911780bC32756D5d1a9Fa9, + 0x322135Dd9cBAE8Afa84727d9aE1434b5B3EBA44B, + 0xC26b89A667578ec7b3f11b2F98d6Fd15C07C54ba, + 0x9409280DC1e6D33AB7A8C6EC03e5763FB61772B5, + 0x5FAE7E604FC3e24fd43A72867ceBaC94c65b404A + // 0x87650D7bbfC3A9F10587d7778206671719d9910D // Uses a token + // that can't be `deal`ed with foundry + // https://etherscan.io/token/0x2a8e1e676ec238d8a992307b495b45b3feaa5e86 + // 0x50f3752289e1456BfA505afd37B241bca23e685d, // Uses a token + // that can't be `deal`ed with foundry + // https://etherscan.io/token/0x3472A5A71965499acd81997a54BBA8D852C6E53d + ]; + + vm.label(address(adapter), "CurveAdapter"); + vm.label(USDT, "USDT"); + vm.label(USDC, "USDC"); + vm.label(STABLE_POOL, "STABLE_POOL"); + vm.label(WETH, "WETH"); + vm.label(CRYPTO_POOL, "CRYPTO_POOL"); + } + + receive() external payable {} + + function testSwapsForAdditionalPools() public { + uint256 len = ADDITIONAL_POOLS_FOR_TESTING.length; + for (uint256 i = 0; i < len; i++) { + bytes32 pair = bytes32(bytes20(ADDITIONAL_POOLS_FOR_TESTING[i])); + address[] memory tokens = adapter.getTokens(pair); + uint256[] memory amounts = new uint256[](1); + + try ICurveStableSwapPool(ADDITIONAL_POOLS_FOR_TESTING[i]).balances( + 0 + ) returns (uint256 bal) { + amounts[0] = bal / 10; + } catch { + amounts[0] = ICurveCustomInt128Pool( + ADDITIONAL_POOLS_FOR_TESTING[i] + ).balances(int128(0)) / 10; + } + + deal(tokens[0], address(this), amounts[0]); + IERC20(tokens[0]).approve(address(adapter), amounts[0]); + + // Test Swap + Trade memory trade = adapter.swap( + pair, + tokens[0], + tokens[1], + ISwapAdapterTypes.OrderSide.Sell, + amounts[0] + ); + + // Test Limits + uint256[] memory limits = + adapter.getLimits(pair, tokens[0], tokens[1]); + + assertGt(trade.calculatedAmount, 0); + assertGt(trade.price.numerator, 0); + assertGt(trade.price.denominator, 0); + assertGt(limits[0], 0); + assertGt(limits[1], 0); + } + } + + function testSwapFuzzCurveStEthPool( + uint256 specifiedAmount, + bool invertedSides + ) public { + (address sellToken, address buyToken) = + !invertedSides ? (ETH, stETH) : (stETH, ETH); + (uint256 sellTokenBalBefore, uint256 buyTokenBalBefore) = (0, 0); + + bytes32 pair = bytes32(bytes20(STETH_POOL)); + uint256[] memory limits = adapter.getLimits(pair, sellToken, buyToken); + + vm.assume(specifiedAmount < limits[0] && specifiedAmount > 10 ** 5); + + if (sellToken == ETH) { + deal(address(adapter), specifiedAmount); + sellTokenBalBefore = address(adapter).balance; + buyTokenBalBefore = IERC20(buyToken).balanceOf(address(this)); + } else { + dealStEthTokens(specifiedAmount); + IERC20(sellToken).approve(address(adapter), specifiedAmount); + sellTokenBalBefore = IERC20(sellToken).balanceOf(address(this)); + buyTokenBalBefore = address(this).balance; + } + + Trade memory trade = adapter.swap( + pair, sellToken, buyToken, OrderSide.Sell, specifiedAmount + ); + + if (sellToken == ETH) { + assertEq( + specifiedAmount, sellTokenBalBefore - address(adapter).balance + ); + assertGe( + trade.calculatedAmount + 3, + IERC20(buyToken).balanceOf(address(this)) - buyTokenBalBefore + ); + assertLe( + trade.calculatedAmount - 3, + IERC20(buyToken).balanceOf(address(this)) - buyTokenBalBefore + ); + } else { + assertGe( + specifiedAmount + 3, + sellTokenBalBefore - IERC20(sellToken).balanceOf(address(this)) + ); + assertLe( + specifiedAmount - 3, + sellTokenBalBefore - IERC20(sellToken).balanceOf(address(this)) + ); + assertEq( + trade.calculatedAmount, + address(this).balance - buyTokenBalBefore + ); + } + } + + function testSwapFuzzCurveStableSwap(uint256 specifiedAmount) public { + OrderSide side = OrderSide.Sell; + + bytes32 pair = bytes32(bytes20(STABLE_POOL)); + uint256[] memory limits = adapter.getLimits(pair, USDC, USDT); + + vm.assume(specifiedAmount < limits[0] && specifiedAmount > 10 ** 4); + + deal(USDC, address(this), specifiedAmount); + IERC20(USDC).approve(address(adapter), specifiedAmount); + + uint256 usdc_balance = IERC20(USDC).balanceOf(address(this)); + uint256 USDT_balance = IERC20(USDT).balanceOf(address(this)); + + Trade memory trade = + adapter.swap(pair, USDC, USDT, side, specifiedAmount); + + assertEq( + specifiedAmount, + usdc_balance - IERC20(USDC).balanceOf(address(this)) + ); + assertEq( + trade.calculatedAmount, + IERC20(USDT).balanceOf(address(this)) - USDT_balance + ); + } + + function testSwapFuzzCurveCryptoSwap(uint256 specifiedAmount) public { + OrderSide side = OrderSide.Sell; + + bytes32 pair = bytes32(bytes20(CRYPTO_POOL)); + uint256[] memory limits = adapter.getLimits(pair, WETH, USDT); + + vm.assume(specifiedAmount < limits[0] && specifiedAmount > 10 ** 6); + + deal(WETH, address(this), specifiedAmount); + IERC20(WETH).approve(address(adapter), specifiedAmount); + + uint256 WETH_balance = IERC20(WETH).balanceOf(address(this)); + uint256 USDT_balance = IERC20(USDT).balanceOf(address(this)); + + Trade memory trade = + adapter.swap(pair, WETH, USDT, side, specifiedAmount); + + assertEq( + specifiedAmount, + WETH_balance - IERC20(WETH).balanceOf(address(this)) + ); + assertEq( + trade.calculatedAmount, + IERC20(USDT).balanceOf(address(this)) - USDT_balance + ); + } + + function testSwapFuzzCurveCryptoSwapUsingEth(uint256 specifiedAmount) + public + { + OrderSide side = OrderSide.Sell; + + bytes32 pair = bytes32(bytes20(CRYPTO_POOL)); + uint256[] memory limits = adapter.getLimits(pair, ETH, USDT); + + vm.assume(specifiedAmount < limits[0] && specifiedAmount > 10 ** 6); + + deal(address(adapter), specifiedAmount); + + uint256 ETH_balance = address(adapter).balance; + uint256 USDT_balance = IERC20(USDT).balanceOf(address(this)); + + Trade memory trade = + adapter.swap(pair, ETH, USDT, side, specifiedAmount); + + assertEq(specifiedAmount, ETH_balance - address(adapter).balance); + assertEq( + trade.calculatedAmount, + IERC20(USDT).balanceOf(address(this)) - USDT_balance + ); + } + + function testSwapFuzzCurveStablePoolEthWithEth(uint256 specifiedAmount) + public + { + OrderSide side = OrderSide.Sell; + + bytes32 pair = bytes32(bytes20(ETH_POOL)); + uint256[] memory limits = adapter.getLimits(pair, ETH, WBETH); + + vm.assume(specifiedAmount < limits[0] && specifiedAmount > 10 ** 14); + + deal(address(adapter), specifiedAmount); + + uint256 eth_balance = address(adapter).balance; + uint256 WBETH_balance = IERC20(WBETH).balanceOf(address(this)); + + Trade memory trade = + adapter.swap(pair, ETH, WBETH, side, specifiedAmount); + + assertEq(specifiedAmount, eth_balance - address(adapter).balance); + assertEq( + trade.calculatedAmount, + IERC20(WBETH).balanceOf(address(this)) - WBETH_balance + ); + } + + function testSwapFuzzCurveStablePoolEthWithToken(uint256 specifiedAmount) + public + { + OrderSide side = OrderSide.Sell; + + bytes32 pair = bytes32(bytes20(ETH_POOL)); + uint256[] memory limits = adapter.getLimits(pair, WBETH, ETH); + + vm.assume(specifiedAmount < limits[0] && specifiedAmount > 10 ** 14); + + deal(address(WBETH), address(this), specifiedAmount); + IERC20(WBETH).approve(address(adapter), specifiedAmount); + + uint256 eth_balance = address(this).balance; + uint256 WBETH_balance = IERC20(WBETH).balanceOf(address(this)); + + Trade memory trade = + adapter.swap(pair, WBETH, ETH, side, specifiedAmount); + + assertEq(trade.calculatedAmount, address(this).balance - eth_balance); + assertEq( + specifiedAmount, + WBETH_balance - IERC20(WBETH).balanceOf(address(this)) + ); + } + + function testSwapSellIncreasingSwapsCurve() public { + executeIncreasingSwapsStableSwap(OrderSide.Sell); + executeIncreasingSwapsCryptoSwap(OrderSide.Sell); + } + + function executeIncreasingSwapsStableSwap(OrderSide side) internal { + bytes32 pair = bytes32(bytes20(CRYPTO_POOL)); + + uint256[] memory amounts = new uint256[](TEST_ITERATIONS); + for (uint256 i = 0; i < TEST_ITERATIONS; i++) { + amounts[i] = 1000 * i * 10 ** 14; + } + + Trade[] memory trades = new Trade[](TEST_ITERATIONS); + uint256 beforeSwap; + for (uint256 i = 0; i < TEST_ITERATIONS; i++) { + beforeSwap = vm.snapshot(); + + deal(WETH, address(this), amounts[i]); + IERC20(WETH).approve(address(adapter), amounts[i]); + + trades[i] = adapter.swap(pair, WETH, USDT, side, amounts[i]); + vm.revertTo(beforeSwap); + } + + for (uint256 i = 1; i < TEST_ITERATIONS - 1; i++) { + assertLe(trades[i].calculatedAmount, trades[i + 1].calculatedAmount); + assertEq(trades[i].price.compareFractions(trades[i + 1].price), 1); + } + } + + function executeIncreasingSwapsCryptoSwap(OrderSide side) internal { + bytes32 pair = bytes32(bytes20(CRYPTO_POOL)); + + uint256[] memory amounts = new uint256[](TEST_ITERATIONS); + for (uint256 i = 0; i < TEST_ITERATIONS; i++) { + amounts[i] = 1000 * i * 10 ** 6; + } + + Trade[] memory trades = new Trade[](TEST_ITERATIONS); + uint256 beforeSwap; + for (uint256 i = 0; i < TEST_ITERATIONS; i++) { + beforeSwap = vm.snapshot(); + + deal(WETH, address(this), amounts[i]); + IERC20(WETH).approve(address(adapter), amounts[i]); + + trades[i] = adapter.swap(pair, WETH, USDT, side, amounts[i]); + vm.revertTo(beforeSwap); + } + + for (uint256 i = 1; i < TEST_ITERATIONS - 1; i++) { + assertLe(trades[i].calculatedAmount, trades[i + 1].calculatedAmount); + assertEq(trades[i].price.compareFractions(trades[i + 1].price), 1); + } + } + + function testGetCapabilitiesCurveSwap(bytes32 pair, address t0, address t1) + public + { + Capability[] memory res = adapter.getCapabilities(pair, t0, t1); + + assertEq(res.length, 2); + } + + function testGetTokensCurveStableSwap() public { + bytes32 pair = bytes32(bytes20(STABLE_POOL)); + address[] memory tokens = adapter.getTokens(pair); + + assertGe(tokens.length, 2); + } + + function testGetTokensCurveCryptoSwap() public { + bytes32 pair = bytes32(bytes20(CRYPTO_POOL)); + address[] memory tokens = adapter.getTokens(pair); + + assertGe(tokens.length, 2); + } + + function testGetLimitsCurveStableSwap() public { + bytes32 pair = bytes32(bytes20(STABLE_POOL)); + uint256[] memory limits = adapter.getLimits(pair, USDC, USDT); + + assertEq(limits.length, 2); + } + + function testGetLimitsCurveCryptoSwap() public { + bytes32 pair = bytes32(bytes20(CRYPTO_POOL)); + uint256[] memory limits = adapter.getLimits(pair, WETH, USDT); + + assertEq(limits.length, 2); + } + + /// @dev custom function to 'deal' stETH tokens as normal deal won't work + function dealStEthTokens(uint256 amount) internal { + uint256 wstETHAmount = wstETH.getStETHByWstETH(amount); + deal(address(wstETH), address(this), wstETHAmount); + wstETH.unwrap(wstETHAmount); + } +} + +interface IwstETH is IERC20 { + function unwrap(uint256 _wstETHAmount) external returns (uint256); + function getStETHByWstETH(uint256 _wstETHAmount) + external + view + returns (uint256); +} diff --git a/evm/test/EtherfiAdapter.t.sol b/evm/test/EtherfiAdapter.t.sol index 366561a..8494ded 100644 --- a/evm/test/EtherfiAdapter.t.sol +++ b/evm/test/EtherfiAdapter.t.sol @@ -28,7 +28,10 @@ contract EtherfiAdapterTest is Test, ISwapAdapterTypes { receive() external payable {} - function testPriceFuzzEtherfi(uint256 amount0, uint256 amount1) public { + function testPriceFuzzEtherfi(uint256 amount0, uint256 amount1) + public + view + { bytes32 pair = bytes32(0); uint256[] memory limits = adapter.getLimits( pair, address(address(weEth)), address(address(eEth)) @@ -319,10 +322,10 @@ contract EtherfiAdapterTest is Test, ISwapAdapterTypes { weEth_.balanceOf(address(this)) - weEth_balance ); /// @dev Transfer function contains rounding errors because of - /// rewards in eETH contract, therefore we assume a +/-2 + /// rewards in eETH contract, therefore we assume a +/-4 /// tolerance assertLe( - specifiedAmount - 2, + specifiedAmount - 4, weEth_.balanceOf(address(this)) - weEth_balance ); assertEq( @@ -386,6 +389,7 @@ contract EtherfiAdapterTest is Test, ISwapAdapterTypes { function testGetCapabilitiesEtherfi(bytes32 pair, address t0, address t1) public + view { Capability[] memory res = adapter.getCapabilities(pair, address(t0), address(t1)); @@ -393,14 +397,14 @@ contract EtherfiAdapterTest is Test, ISwapAdapterTypes { assertEq(res.length, 3); } - function testGetTokensEtherfi() public { + function testGetTokensEtherfi() public view { bytes32 pair = bytes32(0); address[] memory tokens = adapter.getTokens(pair); assertEq(tokens.length, 3); } - function testGetLimitsEtherfi() public { + function testGetLimitsEtherfi() public view { bytes32 pair = bytes32(0); uint256[] memory limits = adapter.getLimits(pair, address(eEth), address(weEth));