From 5831aecc961343e4bc06d0e4d1575b977b54aede Mon Sep 17 00:00:00 2001 From: czanella Date: Wed, 17 Jul 2024 15:02:22 +0100 Subject: [PATCH] Implement adapter integration test and use it on uniswap and balancer --- evm/src/balancer-v2/BalancerV2SwapAdapter.sol | 3 +- evm/src/interfaces/ISwapAdapterTypes.sol | 5 +- evm/test/AdapterTest.sol | 235 ++++++++++++++++++ evm/test/BalancerV2SwapAdapter.t.sol | 11 +- evm/test/UniswapV2SwapAdapter.t.sol | 10 +- 5 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 evm/test/AdapterTest.sol diff --git a/evm/src/balancer-v2/BalancerV2SwapAdapter.sol b/evm/src/balancer-v2/BalancerV2SwapAdapter.sol index e93702b..9aacef9 100644 --- a/evm/src/balancer-v2/BalancerV2SwapAdapter.sol +++ b/evm/src/balancer-v2/BalancerV2SwapAdapter.sol @@ -188,10 +188,11 @@ contract BalancerV2SwapAdapter is ISwapAdapter { override returns (Capability[] memory capabilities) { - capabilities = new Capability[](3); + capabilities = new Capability[](4); capabilities[0] = Capability.SellOrder; capabilities[1] = Capability.BuyOrder; capabilities[2] = Capability.PriceFunction; + capabilities[3] = Capability.HardLimits; } function getTokens(bytes32 poolId) diff --git a/evm/src/interfaces/ISwapAdapterTypes.sol b/evm/src/interfaces/ISwapAdapterTypes.sol index 85e12f4..cb9e5e5 100644 --- a/evm/src/interfaces/ISwapAdapterTypes.sol +++ b/evm/src/interfaces/ISwapAdapterTypes.sol @@ -30,7 +30,10 @@ interface ISwapAdapterTypes { TokenBalanceIndependent, // Indicates that prices are returned scaled, else it is assumed prices // still require scaling by token decimals. (required) - ScaledPrices + ScaledPrices, + // Indicates that if we try to go over the sell limits, the pool will + // revert (optional) + HardLimits } /// @dev Representation used for rational numbers such as prices. diff --git a/evm/test/AdapterTest.sol b/evm/test/AdapterTest.sol new file mode 100644 index 0000000..cb3b26a --- /dev/null +++ b/evm/test/AdapterTest.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import {ISwapAdapter} from "src/interfaces/ISwapAdapter.sol"; +import "src/interfaces/ISwapAdapterTypes.sol"; +import "src/libraries/FractionMath.sol"; + +contract AdapterTest is Test, ISwapAdapterTypes { + using FractionMath for Fraction; + + uint256 constant pricePrecision = 10e24; + string[] public stringPctgs = ["0%", "0.01%", "50%", "100%"]; + + // @notice Test the behavior of a swap adapter for a list of pools + // @dev Computes limits, prices, and swaps on the pools on both directions + // for different + // sell amounts. Asserts that the prices behaves as expected. + // @param adapter The swap adapter to test + // @param poolIds The list of pool ids to test + function testPoolBehaviour(ISwapAdapter adapter, bytes32[] memory poolIds) + public + { + bool hasPriceImpact = !hasCapability( + adapter.getCapabilities(poolIds[0], address(0), address(0)), + Capability.ConstantPrice + ); + for (uint256 i = 0; i < poolIds.length; i++) { + address[] memory tokens = adapter.getTokens(poolIds[i]); + IERC20(tokens[0]).approve(address(adapter), type(uint256).max); + + testPricesForPair( + adapter, poolIds[i], tokens[0], tokens[1], hasPriceImpact + ); + testPricesForPair( + adapter, poolIds[i], tokens[1], tokens[0], hasPriceImpact + ); + } + } + + // Prices should: + // 1. Be monotonic decreasing + // 2. Be positive + // 3. Always be >= the executed price and >= the price after the swap + function testPricesForPair( + ISwapAdapter adapter, + bytes32 poolId, + address tokenIn, + address tokenOut, + bool hasPriceImpact + ) internal { + uint256 sellLimit = adapter.getLimits(poolId, tokenIn, tokenOut)[0]; + assertGt(sellLimit, 0, "Sell limit should be greater than 0"); + + console2.log( + "TEST: Testing prices for pair %s -> %s. Sell limit: %d", + tokenIn, + tokenOut, + sellLimit + ); + uint256[] memory amounts = calculateTestAmounts(sellLimit); + Fraction[] memory prices = + adapter.price(poolId, tokenIn, tokenOut, amounts); + assertGt( + fractionToInt(prices[0]), + fractionToInt(prices[prices.length - 1]), + "Price at limit should be smaller than price at 0" + ); + console2.log( + "TEST: Price at 0: %d, price at sell limit: %d", + fractionToInt(prices[0]), + fractionToInt(prices[prices.length - 1]) + ); + + console2.log("TEST: Testing behavior for price at 0"); + assertGt(prices[0].numerator, 0, "Nominator shouldn't be 0"); + assertGt(prices[0].denominator, 0, "Denominator shouldn't be 0"); + + Trade memory trade; + deal(tokenIn, address(this), 2 * amounts[amounts.length - 1]); + + for (uint256 j = 1; j < amounts.length; j++) { + console2.log( + "TEST: Testing behavior for price at %s of limit: %d", + stringPctgs[j], + amounts[j] + ); + uint256 priceAtAmount = fractionToInt(prices[j]); + assertGt(prices[j].numerator, 0, "Nominator shouldn't be 0"); + assertGt(prices[j].denominator, 0, "Denominator shouldn't be 0"); + + console2.log("TEST: Swapping %d of %s", amounts[j], tokenIn); + trade = adapter.swap( + poolId, tokenIn, tokenOut, OrderSide.Sell, amounts[j] + ); + uint256 executedPrice = + trade.calculatedAmount * pricePrecision / amounts[j]; + uint256 priceAfterSwap = fractionToInt(trade.price); + console2.log("TEST: Pool price: %d", priceAtAmount); + console2.log("TEST: Executed price: %d", executedPrice); + console2.log("TEST: Price after swap: %d", priceAfterSwap); + + if (hasPriceImpact) { + assertGt( + priceAtAmount, + executedPrice, + "Price should be greated than executed price." + ); + assertGt( + executedPrice, + priceAfterSwap, + "Executed price should be greater than price after swap." + ); + assertGt( + priceAtAmount, + priceAfterSwap, + "Price should be greated than price after swap." + ); + } else { + assertGe( + priceAtAmount, + executedPrice, + "Price should be greated than executed price." + ); + assertGe( + executedPrice, + priceAfterSwap, + "Executed price should be greater than price after swap." + ); + assertGe( + priceAtAmount, + priceAfterSwap, + "Price should be greated than price after swap." + ); + } + } + uint256 amountAboveLimit = sellLimit * 105 / 100; + + bool hasHardLimits = hasCapability( + adapter.getCapabilities(poolId, tokenIn, tokenOut), + Capability.HardLimits + ); + + if (hasHardLimits) { + testRevertAboveLimit( + adapter, poolId, tokenIn, tokenOut, amountAboveLimit + ); + } else { + testOperationsAboveLimit( + adapter, poolId, tokenIn, tokenOut, amountAboveLimit + ); + } + } + + function testRevertAboveLimit( + ISwapAdapter adapter, + bytes32 poolId, + address tokenIn, + address tokenOut, + uint256 amountAboveLimit + ) internal { + console2.log("TEST: Testing revert behavior above the sell limit"); + uint256[] memory aboveLimitArray = new uint256[](1); + aboveLimitArray[0] = amountAboveLimit; + + try adapter.price(poolId, tokenIn, tokenOut, aboveLimitArray) { + revert("Pool shouldn't be fetch prices above the sell limit"); + } catch Error(string memory s) { + console2.log( + "TEST: Expected error when fetching price above limit: %s", s + ); + } + try adapter.swap( + poolId, tokenIn, tokenOut, OrderSide.Sell, aboveLimitArray[0] + ) { + revert("Pool shouldn't be able to swap above the sell limit"); + } catch Error(string memory s) { + console2.log( + "TEST: Expected error when swapping above limit: %s", s + ); + } + } + + function testOperationsAboveLimit( + ISwapAdapter adapter, + bytes32 poolId, + address tokenIn, + address tokenOut, + uint256 amountAboveLimit + ) internal { + console2.log("TEST: Testing operations above the sell limit"); + uint256[] memory aboveLimitArray = new uint256[](1); + aboveLimitArray[0] = amountAboveLimit; + + adapter.price(poolId, tokenIn, tokenOut, aboveLimitArray); + adapter.swap( + poolId, tokenIn, tokenOut, OrderSide.Sell, aboveLimitArray[0] + ); + } + + function calculateTestAmounts(uint256 limit) + internal + pure + returns (uint256[] memory) + { + uint256[] memory amounts = new uint256[](4); + amounts[0] = 0; + amounts[1] = limit / 10000; + amounts[2] = limit / 2; + amounts[3] = limit; + return amounts; + } + + function fractionToInt(Fraction memory price) + public + pure + returns (uint256) + { + return price.numerator * pricePrecision / price.denominator; + } + + function hasCapability( + Capability[] memory capabilities, + Capability capability + ) internal pure returns (bool) { + for (uint256 i = 0; i < capabilities.length; i++) { + if (capabilities[i] == capability) { + return true; + } + } + + return false; + } +} diff --git a/evm/test/BalancerV2SwapAdapter.t.sol b/evm/test/BalancerV2SwapAdapter.t.sol index 20f4f8d..e4cb188 100644 --- a/evm/test/BalancerV2SwapAdapter.t.sol +++ b/evm/test/BalancerV2SwapAdapter.t.sol @@ -1,16 +1,15 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.13; -import "forge-std/Test.sol"; +import "./AdapterTest.sol"; import { BalancerV2SwapAdapter, IERC20, IVault } from "src/balancer-v2/BalancerV2SwapAdapter.sol"; -import {ISwapAdapterTypes} from "src/interfaces/ISwapAdapterTypes.sol"; import {FractionMath} from "src/libraries/FractionMath.sol"; -contract BalancerV2SwapAdapterTest is Test, ISwapAdapterTypes { +contract BalancerV2SwapAdapterTest is AdapterTest { using FractionMath for Fraction; IVault constant balancerV2Vault = @@ -229,4 +228,10 @@ contract BalancerV2SwapAdapterTest is Test, ISwapAdapterTypes { ); adapter.getPoolIds(100, 200); } + + function testBalancerV2PoolBehaviour() public { + bytes32[] memory poolIds = new bytes32[](1); + poolIds[0] = B_80BAL_20WETH_POOL_ID; + testPoolBehaviour(adapter, poolIds); + } } diff --git a/evm/test/UniswapV2SwapAdapter.t.sol b/evm/test/UniswapV2SwapAdapter.t.sol index b275c82..d3aebbd 100644 --- a/evm/test/UniswapV2SwapAdapter.t.sol +++ b/evm/test/UniswapV2SwapAdapter.t.sol @@ -1,13 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pragma solidity ^0.8.13; -import "forge-std/Test.sol"; +import "./AdapterTest.sol"; import "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; import "src/uniswap-v2/UniswapV2SwapAdapter.sol"; import "src/interfaces/ISwapAdapterTypes.sol"; import "src/libraries/FractionMath.sol"; -contract UniswapV2PairFunctionTest is Test, ISwapAdapterTypes { +contract UniswapV2PairFunctionTest is AdapterTest { using FractionMath for Fraction; UniswapV2SwapAdapter adapter; @@ -160,4 +160,10 @@ contract UniswapV2PairFunctionTest is Test, ISwapAdapterTypes { assertEq(limits.length, 2); } + + function testUsv2PoolBehaviour() public { + bytes32[] memory poolIds = new bytes32[](1); + poolIds[0] = bytes32(bytes20(USDC_WETH_PAIR)); + testPoolBehaviour(adapter, poolIds); + } }