// 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"; import "src/libraries/EfficientERC20.sol"; contract AdapterTest is Test, ISwapAdapterTypes { using FractionMath for Fraction; using EfficientERC20 for IERC20; string[] public stringPctgs = ["0%", "0.1%", "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 runPoolBehaviourTest( 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]).forceApprove(address(adapter), type(uint256).max); IERC20(tokens[1]).forceApprove(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 (within rounding tolerance) // 2. Be positive // 3. Always be >= the executed price and >= the price after the swap // (within rounding tolerance) 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 ); bool hasMarginalPrices = hasCapability( adapter.getCapabilities(poolId, tokenIn, tokenOut), Capability.MarginalPrice ); 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( fractionToInt(prices[0]) // within rounding tolerance * (amounts[amounts.length - 1] + 1) / amounts[amounts.length - 1], 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"); uint256 priceAtZero = fractionToInt(prices[0]); console2.log("TEST: Price at 0: %d", priceAtZero); deal(tokenIn, address(this), 5 * amounts[amounts.length - 1]); uint256 initialState = vm.snapshot(); for (uint256 j = 1; j < amounts.length; j++) { console2.log( "TEST: Testing behavior for price at %s of limit.", stringPctgs[j], amounts[j] ); uint256 priceAtAmount = fractionToInt(prices[j]); // We allow the assertions to tolerate rounding errors // not greater than `1/amounts[j]` uint256 toleranceDenominator = amounts[j]; console2.log("TEST: Swapping %d of %s", amounts[j], tokenIn); try adapter.swap( poolId, tokenIn, tokenOut, OrderSide.Sell, amounts[j] ) returns ( Trade memory trade ) { uint256 executedPrice = Fraction( trade.calculatedAmount, amounts[j] ).toQ128x128(); uint256 priceAfterSwap = fractionToInt(trade.price); console2.log("TEST: - Executed price: %d", executedPrice); console2.log("TEST: - Price at amount: %d", priceAtAmount); console2.log("TEST: - Price after swap: %d", priceAfterSwap); if (hasPriceImpact) { assertGeTol( executedPrice, priceAtAmount, toleranceDenominator, "Price should be greater than executed price." ); assertGtTol( executedPrice, priceAfterSwap, toleranceDenominator, "Executed price should be greater than price after swap." ); assertGtTol( priceAtZero, executedPrice, toleranceDenominator, "Price should be greater than price after swap." ); } else { assertGeTol( priceAtZero, priceAfterSwap, toleranceDenominator, "Executed price should be or equal to price after swap." ); assertGeTol( priceAtZero, priceAtAmount, toleranceDenominator, "Executed price should be or equal to price after swap." ); assertGeTol( priceAtZero, executedPrice, toleranceDenominator, "Price should be or equal to price after swap." ); } } catch (bytes memory reason) { (bool isTooSmall, uint256 lowerLimit) = decodeTooSmallError(reason); (bool isLimitExceeded, uint256 limit) = decodeLimitExceededError(reason); if (isTooSmall) { // We allow a TooSmall exception to occur for the smallest // amount only. if (j == 1) { console2.log( "TEST: TooSmall exception tolerated for smallest amount" ); } else { revert( "TEST: TooSmall thrown for a significantly sized amount" ); } } else if (isLimitExceeded) { // We never allow LimitExceeded to be thrown, since all // amounts should be within the stated limits. revert( "TEST: LimitExceeded thrown for an amount within limits" ); } else { // any other revert reason bubbles up assembly { revert(add(reason, 32), mload(reason)) } } } vm.revertTo(initialState); } 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 ); } console2.log("TEST: All tests passed."); } function testRevertAboveLimit( ISwapAdapter adapter, bytes32 poolId, address tokenIn, address tokenOut, uint256 amountAboveLimit ) internal { console2.log( "TEST: Testing revert behavior above the sell limit: %d", amountAboveLimit ); uint256[] memory aboveLimitArray = new uint256[](1); aboveLimitArray[0] = amountAboveLimit; bool supportsLimitExceeded = false; try adapter.price(poolId, tokenIn, tokenOut, aboveLimitArray) { revert( "Pool shouldn't be able to fetch prices above the sell limit" ); } catch (bytes memory reason) { (bool isTooSmall, uint256 lowerLimit) = decodeTooSmallError(reason); (bool isLimitExceeded, uint256 limit) = decodeLimitExceededError(reason); if (isLimitExceeded) { supportsLimitExceeded = true; console2.log( "TEST: LimitExceeded supported! Thrown when fetching price above limit: %i", limit ); } else if (isTooSmall) { console2.log( "TEST: UNEXPECTED TooSmall error when fetching price below limit: %i", lowerLimit ); revert TooSmall(lowerLimit); } else if ( reason.length >= 4 && bytes4(reason) == bytes4(keccak256("Error(string)")) ) { string memory s = abi.decode( sliceBytes(reason, 4, reason.length - 4), (string) ); console2.log( "TEST: Expected error when fetching price above limit: %s", s ); } else { // Unexpected error type: re-raise. assembly { revert(add(reason, 32), mload(reason)) } } } try adapter.swap( poolId, tokenIn, tokenOut, OrderSide.Sell, aboveLimitArray[0] ) { revert("Pool shouldn't be able to swap above the sell limit"); } catch (bytes memory reason) { (bool isTooSmall, uint256 lowerLimit) = decodeTooSmallError(reason); (bool isLimitExceeded, uint256 limit) = decodeLimitExceededError(reason); if (isLimitExceeded) { supportsLimitExceeded = true; console2.log( "TEST: LimitExceeded supported! Thrown when swapping above limit: %i", limit ); } else if (isTooSmall) { console2.log( "TEST: UNEXPECTED TooSmall error when swapping above limit: %i", lowerLimit ); revert TooSmall(lowerLimit); } else if ( reason.length >= 4 && bytes4(reason) == bytes4(keccak256("Error(string)")) ) { string memory s = abi.decode( sliceBytes(reason, 4, reason.length - 4), (string) ); console2.log( "TEST: Expected error when swapping above limit: %s", s ); } else { // Unexpected error type: re-raise. assembly { revert(add(reason, 32), mload(reason)) } } } if (supportsLimitExceeded) { console.log(unicode"Adapter supports LimitExceeded ✓"); } } function testOperationsAboveLimit( ISwapAdapter adapter, bytes32 poolId, address tokenIn, address tokenOut, uint256 amountAboveLimit ) internal { console2.log( "TEST: Testing operations above the sell limit: %d", amountAboveLimit ); 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, bool hasMarginalPrices) internal pure returns (uint256[] memory) { uint256[] memory amounts = new uint256[](4); amounts[0] = hasMarginalPrices ? 0 : limit / 10000; amounts[1] = limit / 1000; amounts[2] = limit / 2; amounts[3] = limit; return amounts; } function fractionToInt(Fraction memory price) public pure returns (uint256) { return price.toQ128x128(); } 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; } // // Custom Error Helper Functions // TODO should we expose these in a better location / library for solvers to // also leverage? // Helper function to check if error is TooSmall and decode it function decodeTooSmallError(bytes memory reason) internal pure returns (bool, uint256) { if (reason.length >= 4 && bytes4(reason) == TooSmall.selector) { if (reason.length == 36) { uint256 lowerLimit = abi.decode(sliceBytes(reason, 4, 32), (uint256)); return (true, lowerLimit); } } return (false, 0); } // Helper function to check if error is LimitExceeded and decode it function decodeLimitExceededError(bytes memory reason) internal pure returns (bool, uint256) { if (reason.length >= 4 && bytes4(reason) == LimitExceeded.selector) { if (reason.length == 36) { uint256 limit = abi.decode(sliceBytes(reason, 4, 32), (uint256)); return (true, limit); } } return (false, 0); } // Helper function to slice bytes function sliceBytes(bytes memory data, uint256 start, uint256 length) internal pure returns (bytes memory) { bytes memory result = new bytes(length); for (uint256 i = 0; i < length; i++) { result[i] = data[start + i]; } return result; } // // Helper functions to assert with tolerance // function assertGeTol( uint256 a, uint256 b, uint256 toleranceDenominator, string memory errorMessage ) internal { // The tolerance is `1 / toleranceDenominator`, so we increase the value // of `a` by this amount. adjustedA = a * (denom+1) / denom uint256 adjustedA = FractionMath.mulDiv( a, toleranceDenominator + 1, toleranceDenominator ); assertGe(adjustedA, b, errorMessage); } function assertGtTol( uint256 a, uint256 b, uint256 toleranceDenominator, string memory errorMessage ) internal { // The tolerance is `1 / toleranceDenominator`, so we increase the value // of `a` by this amount. adjustedA = a * (denom+1) / denom uint256 adjustedA = FractionMath.mulDiv( a, toleranceDenominator + 1, toleranceDenominator ); assertGt(adjustedA, b, errorMessage); } }