diff --git a/foundry.toml b/foundry.toml index 4ff40c4..488edaf 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,4 +3,12 @@ src = "src" out = "out" libs = ["lib"] -# See more config options https://github.com/foundry-rs/foundry/tree/master/config \ No newline at end of file +[rpc_endpoints] +mainnet = "${ETH_RPC_URL}" + + +[fmt] +line_length = 80 + +[etherscan] +mainnet = { key = "${ETHERSCAN_MAINNET_KEY}" } diff --git a/interfaces/IPairFunctions.sol b/interfaces/IPairFunctions.sol index da5824c..68656fc 100644 --- a/interfaces/IPairFunctions.sol +++ b/interfaces/IPairFunctions.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.13; import "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import "interfaces/IPairFunctionsTypes.sol"; /// @title IPairFunctions /// @dev Implement this interface to support propeller routing through your pairs. @@ -17,91 +18,48 @@ import "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; /// @dev During calls to price, swap and getLimits, the caller can be assumed to /// @dev have the required sell or buy token balance as well as unlimited approvals /// @dev to this contract. -interface IPairFunctions { - /// @dev The SwapSide enum represents possible sides of a trade: Sell or Buy. - /// @dev E.g. if SwapSide is Sell, the sell amount is interpreted to be fixed. - enum SwapSide { - Sell, - Buy - } - - /// @dev The Capabilities enum represents possible features of a trading pair. - enum Capabilities - // Support SwapSide.Sell values (required) - { - SellSide, - // Support SwapSide.Buy values (optional) - BuySide, - // Support evaluating the price function (optional) - PriceFunction, - // Support tokens that charge a fee on transfer (optional) - FeeOnTransfer, - // The pair does not suffer from price impact and mantains - // a constant price for increasingly larger speficied amounts. - // (optional) - ConstantPrice, - // Indicates that the pair does not read it's own token balances - // while swapping. (optional) - TokenBalanceIndependent - } - - /// @dev Representation used for rational numbers such as prices. - struct Fraction { - uint256 nominator; - uint256 denominator; - } - - /// @dev The Trade struct holds data about an executed trade. - struct Trade { - uint256 receivedAmount; // The amount received from the trade. - uint256 gasUsed; // The amount of gas used in the trade. - uint256 Fraction; // The price of the pair after the trade. - } - - /// @dev The Unavailable error is thrown when a pool or swap is not - /// @dev available for unexpected reason, e.g. because it was paused - /// @dev due to a bug. - error Unavailable(string reason); - - /// @dev The LimitExceeded error is thrown when a limit has been - /// @dev exceeded. E.g. the specified amount can't be traded safely. - error LimitExceeded(uint256 limit); - +interface IPairFunctions is IPairFunctionTypes { /// @notice Calculates pair prices for specified amounts (optional). /// @dev The returned prices should include all dex fees, in case the fee /// @dev is dynamic, the returned price is expected to include the minimum fee. /// @dev Ideally this method should be implemented, although it is optional as /// @dev the price function can be numerically estimated from the swap function. /// @dev In case it is not available it should be flagged via capabilities and - /// @dev calling it should revert using the `NotImplemented` error. In case implemented - /// @dev the method should ideally be view as this is usually more efficient - /// @dev and can be run in parallel. If necessary though, the method is allowed to - /// @dev be state changing this is still better than not providing a implementation + /// @dev calling it should revert using the `NotImplemented` error. + /// @dev The method needs to be implemented as view as this is usually more efficient + /// @dev and can be run in parallel. /// @dev all. /// @param pairId The ID of the trading pair. /// @param sellToken The token being sold. /// @param buyToken The token being bought. - /// @param specifiedAmounts The specified amounts used for price calculation. + /// @param sellAmounts The specified amounts used for price calculation. /// @return prices array of prices as fractions corresponding to the provided amounts. - function price(bytes32 pairId, IERC20 sellToken, IERC20 buyToken, uint256[] memory sellAmounts) - external - returns (Fraction[] prices); + function price( + bytes32 pairId, + IERC20 sellToken, + IERC20 buyToken, + uint256[] memory sellAmounts + ) external view returns (Fraction[] memory prices); /// @notice Simulates swapping tokens on a given pair. /// @dev This function should be state modifying meaning it should actually execute /// @dev the swap and change the state of the evm accordingly. /// @dev Please include a gas usage estimate for each amount. This can be achieved - /// @dev by using the `gasleft()` function. + /// @dev e.g. by using the `gasleft()` function. /// @dev /// @param pairId The ID of the trading pair. /// @param sellToken The token being sold. /// @param buyToken The token being bought. /// @param side The side of the trade (Sell or Buy). - /// @param specifiedAmounts The amounts to be traded. - /// @return trades array of Trade structs representing each executed trade. - function swap(bytes32 pairId, IERC20 sellToken, IERC20 buyToken, SwapSide side, uint256[] memory specifiedAmounts) - external - returns (Trade[] trades); + /// @param specifiedAmount The amount to be traded. + /// @return trade Trade struct representing the executed trade. + function swap( + bytes32 pairId, + IERC20 sellToken, + IERC20 buyToken, + SwapSide side, + uint256 specifiedAmount + ) external returns (Trade memory trade); /// @notice Retrieves the limits for each token. /// @dev Retrieve the maximum limits of a token that can be traded. The limit is reached @@ -110,19 +68,29 @@ interface IPairFunctions { /// @dev called with amounts below the limit. /// @param pairId The ID of the trading pair. /// @return An array of limits. - function getLimits(bytes32 pairId, SwapSide side) external returns (uint256[]); + function getLimits(bytes32 pairId, SwapSide side) + external + returns (uint256[] memory); /// @notice Retrieves the capabilities of the selected pair. /// @param pairId The ID of the trading pair. /// @return An array of Capabilities. - function getCapabilities(bytes32 pairId, IERC20 sellToken, IERC20 buyToken) external returns (Capabilities[]); + function getCapabilities(bytes32 pairId, IERC20 sellToken, IERC20 buyToken) + external + returns (Capabilities[] memory); + + /// @notice Minimum gas usage of exchange logic excluding transfers. + /// @return gasUsage the amount of gas used by the exchange logic + function minGasUsage() external view returns (uint256); /// @notice Retrieves the tokens in the selected pair. /// @dev Mainly used for testing as this is redundant with the required substreams /// @dev implementation. /// @param pairId The ID of the trading pair. /// @return tokens array of IERC20 contracts. - function getTokens(bytes32 pairId) external returns (IERC20[] tokens); + function getTokens(bytes32 pairId) + external + returns (IERC20[] memory tokens); /// @notice Retrieves a range of pool IDs. /// @dev Mainly used for testing it is alright to not return all available pools here. @@ -131,5 +99,7 @@ interface IPairFunctions { /// @param offset The starting index from which to retrieve pool IDs. /// @param limit The maximum number of pool IDs to retrieve. /// @return ids array of pool IDs. - function getPoolIds(uint256 offset, uint256 limit) external returns (bytes32[] ids); + function getPoolIds(uint256 offset, uint256 limit) + external + returns (bytes32[] memory ids); } diff --git a/interfaces/IPairFunctionsTypes.sol b/interfaces/IPairFunctionsTypes.sol new file mode 100644 index 0000000..68364f8 --- /dev/null +++ b/interfaces/IPairFunctionsTypes.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +import "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; + +interface IPairFunctionTypes { + /// @dev The SwapSide enum represents possible sides of a trade: Sell or Buy. + /// @dev E.g. if SwapSide is Sell, the sell amount is interpreted to be fixed. + enum SwapSide { + Sell, + Buy + } + + /// @dev The Capabilities enum represents possible features of a trading pair. + enum Capabilities + // Support SwapSide.Sell values (required) + { + SellSide, + // Support SwapSide.Buy values (optional) + BuySide, + // Support evaluating the price function (optional) + PriceFunction, + // Support tokens that charge a fee on transfer (optional) + FeeOnTransfer, + // The pair does not suffer from price impact and mantains + // a constant price for increasingly larger speficied amounts. + // (optional) + ConstantPrice, + // Indicates that the pair does not read it's own token balances + // while swapping. (optional) + TokenBalanceIndependent, + // Indicates that prices are returned scaled, else it is assumed + // prices still require scaling by token decimals. + ScaledPrices + } + + /// @dev Representation used for rational numbers such as prices. + struct Fraction { + // TODO: rename numerator + uint256 nominator; + uint256 denominator; + } + + /// @dev The Trade struct holds data about an executed trade. + struct Trade { + uint256 receivedAmount; // The amount received from the trade. + uint256 gasUsed; // The amount of gas used in the trade. + Fraction price; // The price of the pair after the trade. + } + + /// @dev The Unavailable error is thrown when a pool or swap is not + /// @dev available for unexpected reason, e.g. because it was paused + /// @dev due to a bug. + error Unavailable(string reason); + + /// @dev The LimitExceeded error is thrown when a limit has been + /// @dev exceeded. E.g. the specified amount can't be traded safely. + error LimitExceeded(uint256 limit); +} diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..d5cf6c1 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,4 @@ +interfaces/=interfaces/ +forge-std/=lib/forge-std/src/ +openzeppelin-contracts/=lib/openzeppelin-contracts/ +src/=src/ \ No newline at end of file diff --git a/src/uniswap-v2/UniswapV2Executor.sol b/src/uniswap-v2/UniswapV2Executor.sol deleted file mode 100644 index 3b44e48..0000000 --- a/src/uniswap-v2/UniswapV2Executor.sol +++ /dev/null @@ -1,2 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; \ No newline at end of file diff --git a/src/uniswap-v2/UniswapV2PairFunctions.sol b/src/uniswap-v2/UniswapV2PairFunctions.sol index 0d2a23e..83f946a 100644 --- a/src/uniswap-v2/UniswapV2PairFunctions.sol +++ b/src/uniswap-v2/UniswapV2PairFunctions.sol @@ -1,2 +1,330 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; + +import "interfaces/IPairFunctions.sol"; + +contract UniswapV2PairFunctions is IPairFunctions { + IUniswapV2Factory immutable factory; + + constructor(address factory_) { + factory = IUniswapV2Factory(factory_); + } + + function price( + bytes32 pairId, + IERC20 sellToken, + IERC20 buyToken, + uint256[] memory sellAmounts + ) external view override returns (Fraction[] memory prices) { + prices = new Fraction[](sellAmounts.length); + IUniswapV2Pair pair = IUniswapV2Pair(address(bytes20(pairId))); + uint112 r0; + uint112 r1; + if (sellToken < buyToken) { + (r0, r1,) = pair.getReserves(); + } else { + (r1, r0,) = pair.getReserves(); + } + + for (uint256 i = 0; i < sellAmounts.length; i++) { + prices[i] = getPriceAt(sellAmounts[i], r0, r1); + } + } + + function getPriceAt(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) + internal + pure + returns (Fraction memory) + { + if (amountIn == 0) { + return Fraction(0, 0); + } + if (reserveIn == 0 || reserveOut == 0) { + revert Unavailable("At least one reserve is zero!"); + } + uint256 amountInWithFee = amountIn * 997; + uint256 numerator = amountInWithFee * reserveOut; + uint256 denominator = (reserveIn * 1000) + amountInWithFee; + uint256 amountOut = numerator / denominator; + uint256 newReserveOut = reserveOut - amountOut; + uint256 newReserveIn = reserveIn + amountIn; + return Fraction(newReserveOut * 1000, newReserveIn * 997); + } + + function swap( + bytes32 pairId, + IERC20 sellToken, + IERC20 buyToken, + SwapSide side, + uint256 specifiedAmount + ) external override returns (Trade memory trade) { + if (specifiedAmount == 0) { + return trade; + } + + IUniswapV2Pair pair = IUniswapV2Pair(address(bytes20(pairId))); + uint256 gasBefore = 0; + uint112 r0; + uint112 r1; + bool zero2one = sellToken < buyToken; + if (zero2one) { + (r0, r1,) = pair.getReserves(); + } else { + (r1, r0,) = pair.getReserves(); + } + gasBefore = gasleft(); + if (side == SwapSide.Sell) { + trade.receivedAmount = + sell(pair, sellToken, zero2one, r0, r1, specifiedAmount); + } else { + trade.receivedAmount = + buy(pair, sellToken, zero2one, r0, r1, specifiedAmount); + } + trade.gasUsed = gasBefore - gasleft(); + trade.price = getPriceAt(specifiedAmount, r0, r1); + } + + function sell( + IUniswapV2Pair pair, + IERC20 sellToken, + bool zero2one, + uint112 reserveIn, + uint112 reserveOut, + uint256 amount + ) internal returns (uint256 receivedAmount) { + address swapper = msg.sender; + // TODO: use safeTransferFrom + sellToken.transferFrom(swapper, address(pair), amount); + uint256 amountOut = getAmountOut(amount, reserveIn, reserveOut); + if (zero2one) { + pair.swap(0, amountOut, swapper, ""); + } else { + pair.swap(amountOut, 0, swapper, ""); + } + return amountOut; + } + + // given an input amount of an asset and pair reserves, returns the maximum output amount of the other asset + function getAmountOut( + uint256 amountIn, + uint256 reserveIn, + uint256 reserveOut + ) internal pure returns (uint256 amountOut) { + if (amountIn == 0) { + return 0; + } + if (reserveIn == 0 || reserveOut == 0) { + revert Unavailable("At least one reserve is zero!"); + } + uint256 amountInWithFee = amountIn * 997; + uint256 numerator = amountInWithFee * reserveOut; + uint256 denominator = reserveIn * 1000 + amountInWithFee; + amountOut = numerator / denominator; + } + + function buy( + IUniswapV2Pair pair, + IERC20 sellToken, + bool zero2one, + uint112 reserveIn, + uint112 reserveOut, + uint256 amountOut + ) internal returns (uint256 receivedAmount) { + address swapper = msg.sender; + uint256 amount = getAmountIn(amountOut, reserveIn, reserveOut); + if (amount == 0) { + return 0; + } + // TODO: use safeTransferFrom + sellToken.transferFrom(swapper, address(pair), amount); + if (zero2one) { + pair.swap(0, amountOut, swapper, ""); + } else { + pair.swap(amountOut, 0, swapper, ""); + } + return amount; + } + + // given an output amount of an asset and pair reserves, returns a required input amount of the other asset + function getAmountIn( + uint256 amountOut, + uint256 reserveIn, + uint256 reserveOut + ) internal pure returns (uint256 amountIn) { + if (amountIn == 0) { + return 0; + } + if (reserveIn == 0 || reserveOut == 0) { + revert Unavailable("At least one reserve is zero!"); + } + uint256 numerator = reserveIn * amountOut * 1000; + uint256 denominator = (reserveOut - amountOut) * 997; + amountIn = (numerator / denominator) + 1; + } + + function getLimits(bytes32 pairId, SwapSide side) + external + view + override + returns (uint256[] memory limits) + { + IUniswapV2Pair pair = IUniswapV2Pair(address(bytes20(pairId))); + limits = new uint256[](2); + (uint256 r0, uint256 r1,) = pair.getReserves(); + if (side == SwapSide.Sell) { + limits[0] = r0 * 10; + limits[1] = r1 * 10; + } else { + limits[0] = r1 * 10; + limits[1] = r0 * 10; + } + } + + function getCapabilities(bytes32, IERC20, IERC20) + external + pure + override + returns (Capabilities[] memory capabilities) + { + capabilities = new Capabilities[](10); + capabilities[0] = Capabilities.SellSide; + capabilities[1] = Capabilities.BuySide; + capabilities[2] = Capabilities.PriceFunction; + } + + function getTokens(bytes32 pairId) + external + view + override + returns (IERC20[] memory tokens) + { + tokens = new IERC20[](2); + IUniswapV2Pair pair = IUniswapV2Pair(address(bytes20(pairId))); + tokens[0] = IERC20(pair.token0()); + tokens[1] = IERC20(pair.token1()); + } + + function getPoolIds(uint256 offset, uint256 limit) + external + view + override + returns (bytes32[] memory ids) + { + uint256 endIdx = offset + limit; + if (endIdx > factory.allPairsLength()) { + endIdx = factory.allPairsLength(); + } + ids = new bytes32[](endIdx - offset); + for (uint256 i = 0; i < ids.length; i++) { + ids[i] = bytes20(factory.allPairs(offset + i)); + } + } + + function minGasUsage() external view returns (uint256) { + return 30000; + } +} + +interface IUniswapV2Pair { + event Approval( + address indexed owner, address indexed spender, uint256 value + ); + event Transfer(address indexed from, address indexed to, uint256 value); + + function name() external pure returns (string memory); + function symbol() external pure returns (string memory); + function decimals() external pure returns (uint8); + function totalSupply() external view returns (uint256); + function balanceOf(address owner) external view returns (uint256); + function allowance(address owner, address spender) + external + view + returns (uint256); + + function approve(address spender, uint256 value) external returns (bool); + function transfer(address to, uint256 value) external returns (bool); + function transferFrom(address from, address to, uint256 value) + external + returns (bool); + + function DOMAIN_SEPARATOR() external view returns (bytes32); + function PERMIT_TYPEHASH() external pure returns (bytes32); + function nonces(address owner) external view returns (uint256); + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external; + + event Mint(address indexed sender, uint256 amount0, uint256 amount1); + event Burn( + address indexed sender, + uint256 amount0, + uint256 amount1, + address indexed to + ); + event Swap( + address indexed sender, + uint256 amount0In, + uint256 amount1In, + uint256 amount0Out, + uint256 amount1Out, + address indexed to + ); + event Sync(uint112 reserve0, uint112 reserve1); + + function MINIMUM_LIQUIDITY() external pure returns (uint256); + function factory() external view returns (address); + function token0() external view returns (address); + function token1() external view returns (address); + function getReserves() + external + view + returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); + function price0CumulativeLast() external view returns (uint256); + function price1CumulativeLast() external view returns (uint256); + function kLast() external view returns (uint256); + + function mint(address to) external returns (uint256 liquidity); + function burn(address to) + external + returns (uint256 amount0, uint256 amount1); + function swap( + uint256 amount0Out, + uint256 amount1Out, + address to, + bytes calldata data + ) external; + function skim(address to) external; + function sync() external; + + function initialize(address, address) external; +} + +interface IUniswapV2Factory { + event PairCreated( + address indexed token0, address indexed token1, address pair, uint256 + ); + + function feeTo() external view returns (address); + function feeToSetter() external view returns (address); + + function getPair(address tokenA, address tokenB) + external + view + returns (address pair); + function allPairs(uint256) external view returns (address pair); + function allPairsLength() external view returns (uint256); + + function createPair(address tokenA, address tokenB) + external + returns (address pair); + + function setFeeTo(address) external; + function setFeeToSetter(address) external; +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 0c4d469..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import "forge-std/Test.sol"; - -contract CounterTest is Test {} diff --git a/test/UniswapV2PairFunction.t.sol b/test/UniswapV2PairFunction.t.sol new file mode 100644 index 0000000..088291f --- /dev/null +++ b/test/UniswapV2PairFunction.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; +import "src/uniswap-v2/UniswapV2PairFunctions.sol"; +import "interfaces/IPairFunctionsTypes.sol"; + +contract UniswapV2PairFunctionTest is Test, IPairFunctionTypes { + UniswapV2PairFunctions pairFunctions; + IERC20 constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); + address constant USDC_WETH_PAIR = 0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc; + + function setUp() public { + uint256 forkBlock = 17000000; + vm.createSelectFork(vm.rpcUrl("mainnet"), forkBlock); + pairFunctions = new + UniswapV2PairFunctions(0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f); + } + + function testPriceFuzz(uint256 amount0, uint256 amount1) public view { + bytes32 pair = bytes32(bytes20(USDC_WETH_PAIR)); + uint256[] memory limits = pairFunctions.getLimits(pair, SwapSide.Sell); + vm.assume(amount0 < limits[0]); + vm.assume(amount1 < limits[0]); + + uint256[] memory amounts = new uint256[](2); + amounts[0] = amount0; + amounts[1] = amount1; + + pairFunctions.price(pair, WETH, USDC, amounts); + } + + function testPriceDecreasing() public { + bytes32 pair = bytes32(bytes20(USDC_WETH_PAIR)); + uint256[] memory amounts = new uint256[](100); + + for (uint256 i = 0; i < 100; i++) { + amounts[i] = 1000 * i * 10 ** 6; + } + + Fraction[] memory prices = + pairFunctions.price(pair, WETH, USDC, amounts); + + for (uint256 i = 1; i < 99; i++) { + assertEq(compareFractions(prices[i], prices[i + 1]), 1); + } + } + + function compareFractions(Fraction memory frac1, Fraction memory frac2) + internal + pure + returns (int8) + { + uint256 crossProduct1 = frac1.nominator * frac2.denominator; + uint256 crossProduct2 = frac2.nominator * frac1.denominator; + + if (crossProduct1 == crossProduct2) return 0; // fractions are equal + + else if (crossProduct1 > crossProduct2) return 1; // frac1 is greater than frac2 + + else return -1; // frac1 is less than frac2 + } + + function testSwapFuzz(uint256 amount, bool isBuy) public { + bytes32 pair = bytes32(bytes20(USDC_WETH_PAIR)); + SwapSide side = SwapSide.Sell; + if (isBuy) { + side = SwapSide.Buy; + } + uint256[] memory limits = pairFunctions.getLimits(pair, side); + vm.assume(amount < limits[0]); + deal(address(USDC), address(this), amount); + USDC.approve(address(pairFunctions), amount); + + pairFunctions.swap(pair, USDC, WETH, side, amount); + } + + function testSwapSellIncreasing() public { + executeIncreasingSwaps(SwapSide.Sell); + } + + function executeIncreasingSwaps(SwapSide side) internal { + bytes32 pair = bytes32(bytes20(USDC_WETH_PAIR)); + + uint256[] memory amounts = new uint256[](100); + for (uint256 i = 0; i < 100; i++) { + amounts[i] = 1000 * i * 10 ** 6; + } + + Trade[] memory trades = new Trade [](100); + uint256 beforeSwap; + for (uint256 i = 0; i < 100; i++) { + beforeSwap = vm.snapshot(); + deal(address(USDC), address(this), amounts[i]); + USDC.approve(address(pairFunctions), amounts[i]); + trades[i] = pairFunctions.swap(pair, USDC, WETH, side, amounts[i]); + vm.revertTo(beforeSwap); + } + + for (uint256 i = 1; i < 99; i++) { + assertLe(trades[i].receivedAmount, trades[i + 1].receivedAmount); + assertLe(trades[i].gasUsed, trades[i + 1].gasUsed); + assertEq(compareFractions(trades[i].price, trades[i + 1].price), 1); + } + } + + function testSwapBuyIncreasing() public { + executeIncreasingSwaps(SwapSide.Buy); + } +}