From ae53dd1a382ef0903831715cb8c59592015a3850 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Sun, 5 Feb 2023 21:23:16 +0200 Subject: [PATCH 01/19] Initial version of univ2 lp deposit after univ3 trade --- src/Recipe2.sol | 169 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/Recipe2.sol diff --git a/src/Recipe2.sol b/src/Recipe2.sol new file mode 100644 index 0000000..0f276c4 --- /dev/null +++ b/src/Recipe2.sol @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: © 2022 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.14; + +import {KilnBase, GemLike} from "./KilnBase.sol"; +import {TwapProduct} from "./uniV3/TwapProduct.sol"; + +// https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol +interface SwapRouterLike { + function exactInput(ExactInputParams calldata params) external returns (uint256 amountOut); + function factory() external returns (address factory); + + // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/interfaces/ISwapRouter.sol#L26 + // https://docs.uniswap.org/protocol/guides/swaps/multihop-swaps#input-parameters + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } +} + +// https://github.com/Uniswap/v2-periphery/blob/dda62473e2da448bc9cb8f4514dadda4aeede5f4/contracts/UniswapV2Router02.sol +interface UniswapV2Router02Like { + function addLiquidity( + address tokenA, + address tokenB, + uint amountADesired, + uint amountBDesired, + uint amountAMin, + uint amountBMin, + address to, + uint deadline + ) external returns (uint amountA, uint amountB, uint liquidity); +} + +contract Recipe2 is KilnBase, TwapProduct { + uint256 public scope; // [Seconds] Time period for TWAP calculations + uint256 public yen; // [WAD] Relative multiplier of the V3 TWAP price to insist on in the UniV3 trade + // For example: 0.98 * WAD allows 2% worse price than the V3 TWAP + uint256 public zen; // [WAD] Allowed Univ2 deviations from Univ3's traded price. Must be <= WAD + // For example: 0.97 * WAD allows 3% price deviation to either side. + bytes public path; // ABI-encoded UniV3 compatible path + + address public immutable uniV2Router; + address public immutable uniV3Router; + address public immutable receiver; + + event File(bytes32 indexed what, bytes data); + + // @notice initialize a Uniswap V3 routing path contract + // @dev In order to complete fire has to trade on UniV3 and deposit to UniV2. With the initial constructor values, + // fire will trade on Univ3 only when the amount of tokens received is equal or better than the Univ3 + // 1 hour average price. + // It will then deposit to Univ2 only if the Univ2 price exactly matches the Univ3 traded price + // (unlikely, therefore at least zen should be reduced from default to support deviations to either direction). + // + // @param _sell the contract address of the token that will be sold + // @param _buy the contract address of the token that will be purchased + // @param _uniV2Router the address of the current Uniswap V2 swap router + // @param _uniV3Router the address of the current Uniswap V3 swap router + // @param _receiver the address of the account which will receive the funds to be bought + constructor( + address _sell, + address _buy, + address _uniV2Router, + address _uniV3Router, + address _receiver + ) + KilnBase(_sell, _buy) + TwapProduct(SwapRouterLike(_uniV3Router).factory()) + { + uniV2Router = _uniV2Router; + uniV3Router = _uniV3Router; + receiver = _receiver; + + scope = 1 hours; + yen = WAD; + zen = WAD; + } + + uint256 constant WAD = 10 ** 18; + + /** + @dev Auth'ed function to update path value + @param what Tag of value to update + @param data Value to update + */ + function file(bytes32 what, bytes calldata data) external auth { + if (what == "path") path = data; + else revert("KilnUniV3/file-unrecognized-param"); + emit File(what, data); + } + + /** + @dev Auth'ed function to update yen, scope, or base contract derived values + Warning - setting `yen` or `zen` as 0 or another low value highly increases the susceptibility to oracle manipulation attacks + Warning - a low `scope` increases the susceptibility to oracle manipulation attacks + @param what Tag of value to update + @param data Value to update + */ + function file(bytes32 what, uint256 data) public override auth { + if (what == "yen") yen = data; + else if (what == "zen") zen = data; + else if (what == "scope") { + require(data > 0, "KilnUniV3/zero-scope"); + require(data <= uint32(type(int32).max), "KilnUniV3/scope-overflow"); + scope = data; + } else { + super.file(what, data); + return; + } + emit File(what, data); + } + + function _swap(uint256 lot) internal override returns (uint256 swapped) { + uint256 halfLot = lot / 2; + GemLike(sell).approve(uniV3Router, halfLot); + + bytes memory _path = path; + uint256 _yen = yen; + uint256 amountMin = (_yen != 0) ? quote(_path, halfLot, uint32(scope)) * _yen / WAD : 0; + + SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ + path: _path, + recipient: address(this), + deadline: block.timestamp, + amountIn: halfLot, + amountOutMinimum: amountMin + }); + uint256 bought = SwapRouterLike(uniV3Router).exactInput(params); + + GemLike(sell).approve(uniV2Router, halfLot); + GemLike(buy).approve(uniV2Router, bought); + (, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ + tokenA: sell, + tokenB: buy, + amountADesired: halfLot, + amountBDesired: bought, + amountAMin: halfLot * zen / WAD, + amountBMin: bought * zen / WAD, + to: receiver, + deadline: block.timestamp + }); + swapped = liquidity; + + // If not all buy tokens were used, send the remainder to the receiver + if (amountB > bought) { + GemLike(buy).transfer(receiver, amountB - bought); + } + } + + function _drop(uint256) internal override {} +} From 86922ad95a7cd18d107bd29cafa12e2c36eebfb1 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Sun, 5 Feb 2023 21:28:04 +0200 Subject: [PATCH 02/19] Fix to if (bought > amountB) --- src/Recipe2.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Recipe2.sol b/src/Recipe2.sol index 0f276c4..4bbfc03 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -160,8 +160,8 @@ contract Recipe2 is KilnBase, TwapProduct { swapped = liquidity; // If not all buy tokens were used, send the remainder to the receiver - if (amountB > bought) { - GemLike(buy).transfer(receiver, amountB - bought); + if (bought > amountB) { + GemLike(buy).transfer(receiver, bought - amountB); } } From a231c4d9d54fb02fb572ef212b601b601ebddb4f Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Tue, 14 Feb 2023 13:57:09 +0200 Subject: [PATCH 03/19] Receipe2 testing - wip --- src/Recipe2.sol | 24 +-- src/Recipe2.t.sol | 380 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 394 insertions(+), 10 deletions(-) create mode 100644 src/Recipe2.t.sol diff --git a/src/Recipe2.sol b/src/Recipe2.sol index 4bbfc03..b17a15c 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: © 2022 Dai Foundation +// SPDX-FileCopyrightText: © 2023 Dai Foundation // SPDX-License-Identifier: AGPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify @@ -16,6 +16,7 @@ pragma solidity ^0.8.14; +import "forge-std/Test.sol"; // TODO: remove import {KilnBase, GemLike} from "./KilnBase.sol"; import {TwapProduct} from "./uniV3/TwapProduct.sol"; @@ -40,13 +41,13 @@ interface UniswapV2Router02Like { function addLiquidity( address tokenA, address tokenB, - uint amountADesired, - uint amountBDesired, - uint amountAMin, - uint amountBMin, + uint256 amountADesired, + uint256 amountBDesired, + uint256 amountAMin, + uint256 amountBMin, address to, - uint deadline - ) external returns (uint amountA, uint amountB, uint liquidity); + uint256 deadline + ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity); } contract Recipe2 is KilnBase, TwapProduct { @@ -132,8 +133,8 @@ contract Recipe2 is KilnBase, TwapProduct { uint256 halfLot = lot / 2; GemLike(sell).approve(uniV3Router, halfLot); - bytes memory _path = path; - uint256 _yen = yen; + bytes memory _path = path; + uint256 _yen = yen; uint256 amountMin = (_yen != 0) ? quote(_path, halfLot, uint32(scope)) * _yen / WAD : 0; SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ @@ -144,10 +145,11 @@ contract Recipe2 is KilnBase, TwapProduct { amountOutMinimum: amountMin }); uint256 bought = SwapRouterLike(uniV3Router).exactInput(params); + console.log("halfLot %s bought %s price %s", halfLot, bought, halfLot / bought); // TODO: remove GemLike(sell).approve(uniV2Router, halfLot); GemLike(buy).approve(uniV2Router, bought); - (, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ + (, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ // TODO: can remove amountA, added it for debug tokenA: sell, tokenB: buy, amountADesired: halfLot, @@ -158,10 +160,12 @@ contract Recipe2 is KilnBase, TwapProduct { deadline: block.timestamp }); swapped = liquidity; + console.log("mkr deposited %s liquidity %s", amountB, liquidity); // TODO: remove // If not all buy tokens were used, send the remainder to the receiver if (bought > amountB) { GemLike(buy).transfer(receiver, bought - amountB); + console.log("sent remaining mkr %s ", bought - amountB); // TODO: remove } } diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol new file mode 100644 index 0000000..97b4c1d --- /dev/null +++ b/src/Recipe2.t.sol @@ -0,0 +1,380 @@ +// SPDX-FileCopyrightText: © 2023 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.14; + +import "forge-std/Test.sol"; +import "./Recipe2.sol"; + +interface TestGem { + function totalSupply() external view returns (uint256); +} + +// https://github.com/Uniswap/v3-periphery/blob/v1.0.0/contracts/lens/UNIV3Quoter.sol#L106-L122 +interface UNIV3Quoter { + function quoteExactInput( + bytes calldata path, + uint256 amountIn + ) external returns (uint256 amountOut); +} + +contract User {} + +contract KilnTest is Test { + Recipe2 kiln; + UNIV3Quoter univ3Quoter; + User user; + + bytes path; + + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + address constant MKR = 0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2; + + uint256 constant WAD = 1e18; + + address constant UNIV2ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; + address constant UNIV2DAIMKRLP = 0x517F9dD285e75b599234F7221227339478d0FcC8; + + address constant UNIV3ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + address constant UNIV3QUOTER = 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6; + address constant UNIV3FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + + event File(bytes32 indexed what, bytes data); + event File(bytes32 indexed what, uint256 data); + + function setUp() public { + user = new User(); + path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); + + kiln = new Recipe2(DAI, MKR, UNIV2ROUTER, UNIV3ROUTER, address(user)); + univ3Quoter = UNIV3Quoter(UNIV3QUOTER); + + kiln.file("lot", 15_000 * WAD); + kiln.file("hop", 6 hours); + kiln.file("path", path); + + kiln.file("yen", 50 * WAD / 100); // Insist on very little on default + kiln.file("zen", 50 * WAD / 100); // Allow large deviations by default + } + + + function mintDai(address usr, uint256 amt) internal { + deal(DAI, usr, amt); + assertEq(GemLike(DAI).balanceOf(address(usr)), amt); + } + + /* + function estimate(uint256 amtIn) internal returns (uint256 amtOut) { + return univ3Quoter.quoteExactInput(path, amtIn); + } + + function swap(address gem, uint256 amount) internal { + GemLike(gem).approve(kiln.uniV3Router(), amount); + + bytes memory _path; + if (gem == DAI) { + _path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); + } else { + _path = abi.encodePacked(MKR, uint24(3000), WETH, uint24(500), USDC, uint24(100), DAI); + } + + ExactInputParams memory params = ExactInputParams( + _path, + address(this), // recipient + block.timestamp, // deadline + amount, // amountIn + 0 // amountOutMinimum + ); + + SwapRouterLike(kiln.uniV3Router()).exactInput(params); + } + */ + + + + + + + /* + function testFilePath() public { + path = abi.encodePacked(DAI, uint24(100), USDC); + vm.expectEmit(true, true, false, false); + emit File(bytes32("path"), path); + kiln.file("path", path); + assertEq0(kiln.path(), path); + } + + function testFileYen() public { + vm.expectEmit(true, true, false, false); + emit File(bytes32("yen"), 42); + kiln.file("yen", 42); + assertEq(kiln.yen(), 42); + } + + function testFileScope() public { + vm.expectEmit(true, true, false, false); + emit File(bytes32("scope"), 314); + kiln.file("scope", 314); + assertEq(kiln.scope(), 314); + } + + function testFileZeroScope() public { + vm.expectRevert("KilnUniV3/zero-scope"); + kiln.file("scope", 0); + } + + function testFileScopeTooLarge() public { + vm.expectRevert("KilnUniV3/scope-overflow"); + kiln.file("scope", uint32(type(int32).max) + 1); + } + + function testFileBytesUnrecognized() public { + vm.expectRevert("KilnUniV3/file-unrecognized-param"); + kiln.file("nonsense", bytes("")); + } + + function testFileUintUnrecognized() public { + vm.expectRevert("KilnBase/file-unrecognized-param"); + kiln.file("nonsense", 23); + } + + function testFilePathNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.file("path", path); + } + + function testFileYenNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.file("yen", 42); + } + + function testFileScopeNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.file("scope", 413); + } + */ + + function testFire() public { // TODO: can remove once we have the other tests + mintDai(address(kiln), 50_000 * WAD); + + assertEq(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); + assertEq(GemLike(DAI).balanceOf(address(kiln)),50_000 * WAD); + + kiln.fire(); + + assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); + assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); + assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); + } + + function testFireYenMuchLessThanTwap() public {} + function testFireYenMuchMoreThanTwap() public {} + function testFireYenZero() public {} + + function testFireAfterLowTwap() public {} + function testFireAfterHighTwap() public {} + + function testFireMkrUpLessThanZen() public {} + function testFireMkrUpMoreThanZen() public {} + function testFireMkrDownLessThanZen() public {} + function testFireMkrDownMoreThanZen() public {} + + function testFireZenWad() public {} + function testFireZenZero() public {} + + /* + function testFireYenMuchLessThanTwap() public { + mintDai(address(kiln), 50_000 * WAD); + + assertEq(GemLike(DAI).balanceOf(address(kiln)), 50_000 * WAD); + uint256 mkrSupply = TestGem(MKR).totalSupply(); + assertTrue(mkrSupply > 0); + + uint256 _est = estimate(50_000 * WAD); + assertTrue(_est > 0); + + assertEq(GemLike(MKR).balanceOf(address(user)), 0); + + kiln.file("yen", 80 * WAD / 100); + kiln.fire(); + + assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); + assertEq(GemLike(MKR).balanceOf(address(user)), _est); + } + + function testFireYenMuchMoreThanTwap() public { + mintDai(address(kiln), 50_000 * WAD); + + assertEq(GemLike(DAI).balanceOf(address(kiln)), 50_000 * WAD); + uint256 mkrSupply = TestGem(MKR).totalSupply(); + assertTrue(mkrSupply > 0); + + uint256 _est = estimate(50_000 * WAD); + assertTrue(_est > 0); + + assertEq(GemLike(MKR).balanceOf(address(user)), 0); + + kiln.file("yen", 120 * WAD / 100); + // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol#L165 + vm.expectRevert("Too little received"); + kiln.fire(); + } + + function testFireYenZero() public { + mintDai(address(kiln), 50_000 * WAD); + + assertEq(GemLike(DAI).balanceOf(address(kiln)), 50_000 * WAD); + uint256 mkrSupply = TestGem(MKR).totalSupply(); + assertTrue(mkrSupply > 0); + + uint256 _est = estimate(50_000 * WAD); + assertTrue(_est > 0); + + assertEq(GemLike(MKR).balanceOf(address(user)), 0); + + kiln.file("yen", 0); + kiln.fire(); + + assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); + assertEq(GemLike(MKR).balanceOf(address(user)), _est); + } + + // Lot is 50k, ensure we can still fire if balance is lower than lot + function testFireLtLot() public { + mintDai(address(kiln), 20_000 * WAD); + + assertEq(GemLike(DAI).balanceOf(address(kiln)), 20_000 * WAD); + uint256 mkrSupply = TestGem(MKR).totalSupply(); + assertTrue(mkrSupply > 0); + + uint256 _est = estimate(20_000 * WAD); + assertTrue(_est > 0); + + assertEq(GemLike(MKR).balanceOf(address(user)), 0); + + kiln.fire(); + + assertEq(GemLike(DAI).balanceOf(address(kiln)), 0); + assertEq(TestGem(MKR).totalSupply(), mkrSupply); + assertEq(GemLike(MKR).balanceOf(address(user)), _est); + } + + // Ensure we only sell off the lot size + function testFireGtLot() public { + mintDai(address(kiln), 100_000 * WAD); + + assertEq(GemLike(DAI).balanceOf(address(kiln)), 100_000 * WAD); + + uint256 _est = estimate(kiln.lot()); + assertTrue(_est > 0); + + kiln.fire(); + + // Due to liquidity constrants, not all of the tokens may be sold + assertTrue(GemLike(DAI).balanceOf(address(kiln)) >= 50_000 * WAD); + assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 100_000 * WAD); + assertEq(GemLike(MKR).balanceOf(address(user)), _est); + } + + function testFireMulti() public { + mintDai(address(kiln), 100_000 * WAD); + + kiln.file("lot", 50 * WAD); // Use a smaller amount due to slippage limits + + kiln.fire(); + + skip(6 hours); + + kiln.fire(); + } + + function testFireAfterLowTwap() public { + mintDai(address(this), 11_000_000 * WAD); // funds for manipulating prices + mintDai(address(kiln), 1_000_000 * WAD); + + kiln.file("hop", 0 hours); // for convenience allow firing right away + kiln.file("scope", 1 hours); + kiln.file("yen", 120 * WAD / 100); // only swap if price rose by 20% vs twap + + uint256 mkrBefore = GemLike(MKR).balanceOf(address(this)); + + // drive down MKR out amount with big DAI->MKR swap + swap(DAI, 10_000_000 * WAD); + + // make sure twap measures low MKR out amount at the beginning of the hour (by making small swap) + vm.roll(block.number + 1); + swap(DAI, WAD / 100); + + // let 1 hour almost pass + skip(1 hours - 1 seconds); + + // make sure twap measures low MKR out amount at the end of the hour (by making small swap) + vm.roll(block.number + 1); + swap(DAI, WAD / 100); + + // fire should fail for low MKR out amount + // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol#L165 + vm.expectRevert("Too little received"); + kiln.fire(); + + // drive MKR out amount back up + swap(MKR, GemLike(MKR).balanceOf(address(this)) - mkrBefore); + + // fire should succeed after MKR amount rose vs twap + kiln.fire(); + } + + function testFireAfterHighTwap() public { + mintDai(address(this), 11_000_000 * WAD); // funds for manipulating prices + mintDai(address(kiln), 1_000_000 * WAD); + + kiln.file("hop", 0 hours); // for convenience allow firing right away + kiln.file("scope", 1 hours); + kiln.file("yen", 80 * WAD / 100); // allow swap even if price fell by 20% vs twap + + // make sure twap measures regular MKR out amount at the beginning of the hour (by making small swap) + vm.roll(block.number + 1); + swap(DAI, WAD / 100); + + // let 1 hour almost pass + skip(1 hours - 1 seconds); + + // make sure twap measures regular MKR out amount at the end of the hour (by making small swap) + vm.roll(block.number + 1); + swap(DAI, WAD / 100); + + // fire should succeed for low yen before any price manipulation + kiln.fire(); + + // drive down MKR out amount with big DAI->MKR swap + swap(DAI, 10_000_000 * WAD); + + // fire should fail when low MKR amount + // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol#L165 + vm.expectRevert("Too little received"); + kiln.fire(); + } + + function testFactoryDerivedFromRouter() public { + assertEq(SwapRouterLike(UNIV3ROUTER).factory(), UNIV3FACTORY); + } + */ +} From 1f309b8a827a30e96dc46da4b4d6c257eee329fa Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Tue, 14 Feb 2023 16:37:47 +0200 Subject: [PATCH 04/19] Use TWAP price also as reference for the Univ2 deposit --- src/Recipe2.sol | 88 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/src/Recipe2.sol b/src/Recipe2.sol index b17a15c..677a2dd 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -52,9 +52,9 @@ interface UniswapV2Router02Like { contract Recipe2 is KilnBase, TwapProduct { uint256 public scope; // [Seconds] Time period for TWAP calculations - uint256 public yen; // [WAD] Relative multiplier of the V3 TWAP price to insist on in the UniV3 trade + uint256 public yen; // [WAD] Relative multiplier of the Univ3 TWAP price to insist on in the UniV3 trade // For example: 0.98 * WAD allows 2% worse price than the V3 TWAP - uint256 public zen; // [WAD] Allowed Univ2 deviations from Univ3's traded price. Must be <= WAD + uint256 public zen; // [WAD] Allowed Univ2 deposit price deviations from the Univ3 TWAP price. Must be <= WAD // For example: 0.97 * WAD allows 3% price deviation to either side. bytes public path; // ABI-encoded UniV3 compatible path @@ -68,7 +68,7 @@ contract Recipe2 is KilnBase, TwapProduct { // @dev In order to complete fire has to trade on UniV3 and deposit to UniV2. With the initial constructor values, // fire will trade on Univ3 only when the amount of tokens received is equal or better than the Univ3 // 1 hour average price. - // It will then deposit to Univ2 only if the Univ2 price exactly matches the Univ3 traded price + // It will then deposit to Univ2 only if the Univ2 price exactly matches the Univ3 TWAP price // (unlikely, therefore at least zen should be reduced from default to support deviations to either direction). // // @param _sell the contract address of the token that will be sold @@ -104,23 +104,27 @@ contract Recipe2 is KilnBase, TwapProduct { */ function file(bytes32 what, bytes calldata data) external auth { if (what == "path") path = data; - else revert("KilnUniV3/file-unrecognized-param"); + else revert("Recipe2/file-unrecognized-param"); emit File(what, data); } /** @dev Auth'ed function to update yen, scope, or base contract derived values - Warning - setting `yen` or `zen` as 0 or another low value highly increases the susceptibility to oracle manipulation attacks + Warning - setting `yen` or `zen` as a low value highly increases the susceptibility to oracle manipulation attacks Warning - a low `scope` increases the susceptibility to oracle manipulation attacks @param what Tag of value to update @param data Value to update */ function file(bytes32 what, uint256 data) public override auth { - if (what == "yen") yen = data; - else if (what == "zen") zen = data; - else if (what == "scope") { - require(data > 0, "KilnUniV3/zero-scope"); - require(data <= uint32(type(int32).max), "KilnUniV3/scope-overflow"); + if (what == "yen") { + require(yen > 0, "Recipe2/zero-yen"); + yen = data; + } else if (what == "zen") { + require(zen > 0, "Recipe2/zero-zen"); + zen = data; + } else if (what == "scope") { + require(data > 0, "Recipe2/zero-scope"); + require(data <= uint32(type(int32).max), "Recipe2/scope-overflow"); scope = data; } else { super.file(what, data); @@ -129,43 +133,65 @@ contract Recipe2 is KilnBase, TwapProduct { emit File(what, data); } + struct Workspace { // TODO: consider moving + uint256 halfLot; + bytes path; + uint256 yen; + uint256 zen; + uint256 quote; + uint256 bought; + } + function _swap(uint256 lot) internal override returns (uint256 swapped) { - uint256 halfLot = lot / 2; - GemLike(sell).approve(uniV3Router, halfLot); - bytes memory _path = path; - uint256 _yen = yen; - uint256 amountMin = (_yen != 0) ? quote(_path, halfLot, uint32(scope)) * _yen / WAD : 0; + Workspace memory ws = Workspace({ + halfLot: lot / 2, + path: path, + yen: yen, + zen: zen, + quote: 0, + bought: 0 + }); + ws.quote = quote(ws.path, ws.halfLot, uint32(scope)); + GemLike(sell).approve(uniV3Router, ws.halfLot); SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ - path: _path, + path: ws.path, recipient: address(this), deadline: block.timestamp, - amountIn: halfLot, - amountOutMinimum: amountMin + amountIn: ws.halfLot, + amountOutMinimum: ws.quote * ws.yen / WAD }); - uint256 bought = SwapRouterLike(uniV3Router).exactInput(params); - console.log("halfLot %s bought %s price %s", halfLot, bought, halfLot / bought); // TODO: remove + ws.bought = SwapRouterLike(uniV3Router).exactInput(params); + console.log("halfLot %s bought %s price %s", ws.halfLot, ws.bought, ws.halfLot / ws.bought); // TODO: remove + + // In case the `sell` token deposit amount needs to be insisted on it means the full `bought` amount of buy tokens are deposited. + // Therefore we want at least the reference price (halfLot / quote) factored by zen. + uint256 sellDepositMin = (ws.bought * ws.halfLot / ws.quote) * ws.zen / WAD; + + // In case the `buy` token deposit amount needs to be insisted on it means the full `halfLot` amount of sell tokens are deposited. + // As `halflot` was also used in the quote calculation, it represents the exact reference price and only needs to be factored by zen + uint256 buyDepositMin = ws.quote * ws.zen / WAD; - GemLike(sell).approve(uniV2Router, halfLot); - GemLike(buy).approve(uniV2Router, bought); - (, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ // TODO: can remove amountA, added it for debug + GemLike(sell).approve(uniV2Router, ws.halfLot); + GemLike(buy).approve(uniV2Router, ws.bought); + (uint256 amountA, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ // TODO: remove amountA as it is not needed tokenA: sell, tokenB: buy, - amountADesired: halfLot, - amountBDesired: bought, - amountAMin: halfLot * zen / WAD, - amountBMin: bought * zen / WAD, + amountADesired: ws.halfLot, + amountBDesired: ws.bought, + amountAMin: sellDepositMin, + amountBMin: buyDepositMin, to: receiver, deadline: block.timestamp }); swapped = liquidity; - console.log("mkr deposited %s liquidity %s", amountB, liquidity); // TODO: remove + console.log("dai deposited %s mkr deposited %s liquidity %s", amountA, amountB, liquidity); // TODO: remove // If not all buy tokens were used, send the remainder to the receiver - if (bought > amountB) { - GemLike(buy).transfer(receiver, bought - amountB); - console.log("sent remaining mkr %s ", bought - amountB); // TODO: remove + if (ws.bought > amountB) { + GemLike(buy).transfer(receiver, ws.bought - amountB); + console.log("sent remaining mkr %s ", ws.bought - amountB); // TODO: remove } } From 08d5a0a8e64cdd56f9238bacaf8cc959ae2d726b Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Mon, 20 Feb 2023 15:40:38 +0200 Subject: [PATCH 05/19] Recipe2.t testing - wip --- src/Recipe2.sol | 2 + src/Recipe2.t.sol | 256 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 247 insertions(+), 11 deletions(-) diff --git a/src/Recipe2.sol b/src/Recipe2.sol index 677a2dd..60c955b 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -173,6 +173,8 @@ contract Recipe2 is KilnBase, TwapProduct { // As `halflot` was also used in the quote calculation, it represents the exact reference price and only needs to be factored by zen uint256 buyDepositMin = ws.quote * ws.zen / WAD; + console.log("sellDepositMin %s buyDepositMin %s", sellDepositMin, buyDepositMin); // TODO: remove + GemLike(sell).approve(uniV2Router, ws.halfLot); GemLike(buy).approve(uniV2Router, ws.bought); (uint256 amountA, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ // TODO: remove amountA as it is not needed diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index 97b4c1d..d20d740 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -19,21 +19,48 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; import "./Recipe2.sol"; +import "src/uniV2/UniswapV2Library.sol"; +import "src/uniV2/IUniswapV2Pair.sol"; + interface TestGem { function totalSupply() external view returns (uint256); } // https://github.com/Uniswap/v3-periphery/blob/v1.0.0/contracts/lens/UNIV3Quoter.sol#L106-L122 -interface UNIV3Quoter { +interface UNIV3Quoter { // TODO: handle caps function quoteExactInput( bytes calldata path, uint256 amountIn ) external returns (uint256 amountOut); } +interface ExtendedUNIV2Router is UniswapV2Router02Like { + function factory() external view returns (address); + function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) external returns (uint256 amountOut); + function swapExactTokensForTokens( + uint amountIn, + uint amountOutMin, + address[] calldata path, + address to, + uint deadline + ) external returns (uint[] memory amounts); +} + +interface UniswapV2FactoryLike { + function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); + +} + +interface UniswapV2PairLike { + function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); +} + contract User {} contract KilnTest is Test { + + using UniswapV2Library for *; + Recipe2 kiln; UNIV3Quoter univ3Quoter; User user; @@ -70,6 +97,8 @@ contract KilnTest is Test { kiln.file("yen", 50 * WAD / 100); // Insist on very little on default kiln.file("zen", 50 * WAD / 100); // Allow large deviations by default + + topUpLiquidity(); } @@ -78,6 +107,39 @@ contract KilnTest is Test { assertEq(GemLike(DAI).balanceOf(address(usr)), amt); } + function topUpLiquidity() internal { + uint256 daiAmt = 1_000_000 * WAD; // TODO: need to start initial liquidity more wisely - first change price, then deposit smaller amount, then start with small lot + uint256 mkrAmt = 1000 * WAD; + + uint reserveA; + uint reserveB; + (address token0,) = UniswapV2Library.sortTokens(DAI, MKR); + address pairToken = UniswapV2Library.pairFor(ExtendedUNIV2Router(UNIV2ROUTER).factory(), DAI, MKR); + + (uint reserve0, uint reserve1,) = UniswapV2PairLike(pairToken).getReserves(); + (reserveA, reserveB) = DAI == token0 ? (reserve0, reserve1) : (reserve1, reserve0); + + mkrAmt = daiAmt / (reserveA / reserveB) - 10 * WAD; + + deal(DAI, address(this), daiAmt); + deal(MKR, address(this), mkrAmt); + + GemLike(DAI).approve(UNIV2ROUTER, daiAmt); + GemLike(MKR).approve(UNIV2ROUTER, mkrAmt); + + UniswapV2Router02Like(UNIV2ROUTER).addLiquidity( + MKR, + DAI, + mkrAmt, + daiAmt, + 0, + 0, + address(this), + block.timestamp); + + assertGt(GemLike(pairToken).balanceOf(address(this)), 0); + } + /* function estimate(uint256 amtIn) internal returns (uint256 amtOut) { return univ3Quoter.quoteExactInput(path, amtIn); @@ -172,7 +234,7 @@ contract KilnTest is Test { } */ - function testFire() public { // TODO: can remove once we have the other tests + function testFire1() public { // TODO: can remove once we have the other tests? mintDai(address(kiln), 50_000 * WAD); assertEq(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); @@ -185,20 +247,192 @@ contract KilnTest is Test { assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); } - function testFireYenMuchLessThanTwap() public {} - function testFireYenMuchMoreThanTwap() public {} - function testFireYenZero() public {} + + /* + + Given a reference TWAP out amount, we want to test the following scenarios. + Note that `Higher` stands for higher out amount than the reference, while `Lower` stands for lower out amount than the reference. + + testFire + ├── Univ3Higher + │ ├── YenAllows (1.00) + │ │ ├── Univ2Higher + │ │ │ ├── ZenAllows (0.95) + │ │ │ └── ZenBlocks (1.0) + │ │ └── Univ2Lower + │ │ ├── ZenAllows (0.95) + │ │ └── ZenBlocks (1.00) + │ └── YenBlocks (1.05) + └── Univ3Lower + ├── YenAllows (0.95) + │ ├── Univ2Higher + │ │ ├── ZenAllows (0.95) + │ │ └── ZenBlocks (1.00) + │ └── Univ2Lower + │ ├── ZenAllows (0.95) + │ └── ZenBlocks (1.00) + └── YenBlocks (1.00) + */ + + function getRefOutAMount(uint256 amountIn) internal returns (uint256) { + return kiln.quote(kiln.path(), amountIn, uint32(kiln.scope())); + } + + + + function changeUniv3Price(uint256 amountIn, uint256 refOutAmount, bool reachHigher) internal { + uint256 current = univ3Quoter.quoteExactInput(path, amountIn); + console.log("current: %s", current); + + // TODO: change actor who does this to address(123) or something + if (reachHigher) { + while (current < refOutAmount) { + + // trade mkr to dai + uint256 mkrAmount = 20 * WAD; + bytes memory path_ = abi.encodePacked(MKR, uint24(3000), WETH, uint24(500), USDC, uint24(100), DAI); + deal(MKR, address(this), mkrAmount); + GemLike(MKR).approve(UNIV3ROUTER, mkrAmount); + SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ + path: path_, + recipient: address(this), + deadline: block.timestamp, + amountIn: mkrAmount, + amountOutMinimum: 0 + }); + SwapRouterLike(UNIV3ROUTER).exactInput(params); + + current = univ3Quoter.quoteExactInput(path, amountIn); + console.log("current: %s", current); + } + } else { + while (current > refOutAmount) { + + // trade dai mkr + uint256 daiAmount = 20_000 * WAD; + deal(DAI, address(this), daiAmount); + GemLike(DAI).approve(UNIV3ROUTER, daiAmount); + SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ + path: path, + recipient: address(this), + deadline: block.timestamp, + amountIn: daiAmount, + amountOutMinimum: 0 + }); + SwapRouterLike(UNIV3ROUTER).exactInput(params); + + current = univ3Quoter.quoteExactInput(path, amountIn); + console.log("current: %s", current); + } + } + } + + function getUniv2AmountOut(uint256 amountIn) internal returns (uint256 amountOut) { + uint reserveA; + uint reserveB; + + (address token0,) = UniswapV2Library.sortTokens(DAI, MKR); + address pairToken = UniswapV2Library.pairFor( ExtendedUNIV2Router(UNIV2ROUTER).factory(), DAI, MKR); + + console.log("pairToken: %s", pairToken); + (uint reserve0, uint reserve1,) = UniswapV2PairLike(pairToken).getReserves(); + (reserveA, reserveB) = DAI == token0 ? (reserve0, reserve1) : (reserve1, reserve0); + + console.log("reserveA: %s", reserveA); + console.log("reserveB: %s", reserveB); + + amountOut = ExtendedUNIV2Router(UNIV2ROUTER).getAmountOut(amountIn, reserveA, reserveB); + } + + function changeUniv2Price(uint256 amountIn, uint256 refOutAmount, bool reachHigher) internal { + + uint256 current = getUniv2AmountOut(amountIn); + console.log("refOutAmount: %s", refOutAmount); + console.log("current: %s", current); + + + // TODO: change actor who does this to address(123) or something + if (reachHigher) { + while (current < refOutAmount) { + + // trade mkr to dai + + address[] memory _path = new address[](2); + _path[0] = MKR; + _path[1] = DAI; + + uint256 mkrAmount = 1 * WAD / 10; + deal(MKR, address(this), mkrAmount); + GemLike(MKR).approve(UNIV2ROUTER, mkrAmount); + ExtendedUNIV2Router(UNIV2ROUTER).swapExactTokensForTokens( + mkrAmount, // amountIn + 0, // amountOutMin + _path, // path + address(this), // to + block.timestamp + ); // deadline + + current = getUniv2AmountOut(amountIn); + console.log("current: %s refOutAmount: %s", current, refOutAmount); + } + } else { + while (current > refOutAmount) { + + // trade dai to mkr + address[] memory _path = new address[](2); + _path[0] = DAI; + _path[1] = MKR; + + uint256 daiAmount = 1000 * WAD; + deal(DAI, address(this), daiAmount); + GemLike(DAI).approve(UNIV2ROUTER, daiAmount); + ExtendedUNIV2Router(UNIV2ROUTER).swapExactTokensForTokens( + daiAmount, // amountIn + 0, // amountOutMin + _path, // path + address(this), // to + block.timestamp + ); // deadline + + current = getUniv2AmountOut(amountIn); + console.log("current: %s", current); } + } + } + + + function testFireUniv3HigherYenAllowsUniv2HigherZenAllows() public { + // get ref price + uint256 ref = getRefOutAMount(20_000 * WAD); + console.log("ref: %s", ref); + + // drive up univ3 out amount + changeUniv3Price(20_000 * WAD, ref, true); + + // set yen to 1.00 + kiln.file("yen", 100 * WAD / 100); + + // drive up univ2 out amount + changeUniv2Price(20_000 * WAD, ref, true); + + // set zen to 0.95 + kiln.file("zen", 95 * WAD / 100); + + // TODO: this currently fail + // since there's almost no liquidity need to make sure when driving price up/dpwn that the price will be very close to the ref + // also need to consider seeding with small amount of initial liquidity + mintDai(address(kiln), 50_000 * WAD); + kiln.fire(); + + // fire + // check success + + } + function testFireAfterLowTwap() public {} function testFireAfterHighTwap() public {} - function testFireMkrUpLessThanZen() public {} - function testFireMkrUpMoreThanZen() public {} - function testFireMkrDownLessThanZen() public {} - function testFireMkrDownMoreThanZen() public {} - function testFireZenWad() public {} - function testFireZenZero() public {} /* function testFireYenMuchLessThanTwap() public { From 9d466fd3f954d1a1b45d2a34176dfe7a258b62e9 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Tue, 21 Feb 2023 22:37:33 +0200 Subject: [PATCH 06/19] Recipe2.t.sol tests - wip --- src/Recipe2.t.sol | 228 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 169 insertions(+), 59 deletions(-) diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index d20d740..6937310 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -107,8 +107,11 @@ contract KilnTest is Test { assertEq(GemLike(DAI).balanceOf(address(usr)), amt); } + // TODO: need to start initial liquidity more wisely - first change price, then deposit smaller amount, then start with small lot + // let's first assume we use topUpLiquidity for 1M dai, then worry about the ramp up + function topUpLiquidity() internal { - uint256 daiAmt = 1_000_000 * WAD; // TODO: need to start initial liquidity more wisely - first change price, then deposit smaller amount, then start with small lot + uint256 daiAmt = 1_000_000 * WAD; uint256 mkrAmt = 1000 * WAD; uint reserveA; @@ -257,11 +260,11 @@ contract KilnTest is Test { ├── Univ3Higher │ ├── YenAllows (1.00) │ │ ├── Univ2Higher - │ │ │ ├── ZenAllows (0.95) - │ │ │ └── ZenBlocks (1.0) + │ │ │ ├── ZenAllows (0.95) V + │ │ │ └── ZenBlocks (1.0) V │ │ └── Univ2Lower - │ │ ├── ZenAllows (0.95) - │ │ └── ZenBlocks (1.00) + │ │ ├── ZenAllows (0.95) V + │ │ └── ZenBlocks (1.00) V │ └── YenBlocks (1.05) └── Univ3Lower ├── YenAllows (0.95) @@ -274,12 +277,11 @@ contract KilnTest is Test { └── YenBlocks (1.00) */ + function getRefOutAMount(uint256 amountIn) internal returns (uint256) { return kiln.quote(kiln.path(), amountIn, uint32(kiln.scope())); } - - function changeUniv3Price(uint256 amountIn, uint256 refOutAmount, bool reachHigher) internal { uint256 current = univ3Quoter.quoteExactInput(path, amountIn); console.log("current: %s", current); @@ -344,63 +346,111 @@ contract KilnTest is Test { amountOut = ExtendedUNIV2Router(UNIV2ROUTER).getAmountOut(amountIn, reserveA, reserveB); } - function changeUniv2Price(uint256 amountIn, uint256 refOutAmount, bool reachHigher) internal { + function changeUniv2Price(uint256 amountIn, uint256 minOutAmount, uint256 maxOutAMount) internal { uint256 current = getUniv2AmountOut(amountIn); - console.log("refOutAmount: %s", refOutAmount); - console.log("current: %s", current); - + console.log("", minOutAmount); + console.log("minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); // TODO: change actor who does this to address(123) or something - if (reachHigher) { - while (current < refOutAmount) { + while (current < minOutAmount) { + + // trade mkr to dai + + address[] memory _path = new address[](2); + _path[0] = MKR; + _path[1] = DAI; + + uint256 mkrAmount = 1 * WAD / 10; + deal(MKR, address(this), mkrAmount); + GemLike(MKR).approve(UNIV2ROUTER, mkrAmount); + ExtendedUNIV2Router(UNIV2ROUTER).swapExactTokensForTokens( + mkrAmount, // amountIn + 0, // amountOutMin + _path, // path + address(this), // to + block.timestamp + ); // deadline + + current = getUniv2AmountOut(amountIn); + console.log("driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + } + while (current > maxOutAMount) { + + // trade dai to mkr + address[] memory _path = new address[](2); + _path[0] = DAI; + _path[1] = MKR; + + uint256 daiAmount = 1000 * WAD; + deal(DAI, address(this), daiAmount); + GemLike(DAI).approve(UNIV2ROUTER, daiAmount); + ExtendedUNIV2Router(UNIV2ROUTER).swapExactTokensForTokens( + daiAmount, // amountIn + 0, // amountOutMin + _path, // path + address(this), // to + block.timestamp + ); // deadline + + current = getUniv2AmountOut(amountIn); + console.log("driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + } - // trade mkr to dai + assert(current >= minOutAmount && current <= maxOutAMount); - address[] memory _path = new address[](2); - _path[0] = MKR; - _path[1] = DAI; + } - uint256 mkrAmount = 1 * WAD / 10; - deal(MKR, address(this), mkrAmount); - GemLike(MKR).approve(UNIV2ROUTER, mkrAmount); - ExtendedUNIV2Router(UNIV2ROUTER).swapExactTokensForTokens( - mkrAmount, // amountIn - 0, // amountOutMin - _path, // path - address(this), // to - block.timestamp - ); // deadline - - current = getUniv2AmountOut(amountIn); - console.log("current: %s refOutAmount: %s", current, refOutAmount); - } - } else { - while (current > refOutAmount) { - // trade dai to mkr - address[] memory _path = new address[](2); - _path[0] = DAI; - _path[1] = MKR; + function testFireUniv3HigherYenAllowsUniv2HigherZenAllows() public { + // get ref price + uint256 ref = getRefOutAMount(20_000 * WAD); + console.log("ref: %s", ref); - uint256 daiAmount = 1000 * WAD; - deal(DAI, address(this), daiAmount); - GemLike(DAI).approve(UNIV2ROUTER, daiAmount); - ExtendedUNIV2Router(UNIV2ROUTER).swapExactTokensForTokens( - daiAmount, // amountIn - 0, // amountOutMin - _path, // path - address(this), // to - block.timestamp - ); // deadline - - current = getUniv2AmountOut(amountIn); - console.log("current: %s", current); } - } + // drive up univ3 out amount + changeUniv3Price(20_000 * WAD, ref, true); + + // set yen to 1.00 + kiln.file("yen", 100 * WAD / 100); + + // change univ2 out amount to be slightly higher than ref + changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); + + // allow 5% margin in uniV2 to either side + kiln.file("zen", 95 * WAD / 100); + + mintDai(address(kiln), 50_000 * WAD); + kiln.fire(); + + // fire + assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); + assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); + assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); } + function testFireUniv3HigherYenAllowsUniv2HigherZenBlocks() public { + // get ref price + uint256 ref = getRefOutAMount(20_000 * WAD); + console.log("ref: %s", ref); - function testFireUniv3HigherYenAllowsUniv2HigherZenAllows() public { + // drive up univ3 out amount + changeUniv3Price(20_000 * WAD, ref, true); + + // set yen to 1.00 + kiln.file("yen", 100 * WAD / 100); + + // change univ2 out amount to be slightly higher than ref + changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); + + kiln.file("zen", 1 * WAD); + + mintDai(address(kiln), 50_000 * WAD); + + vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); + kiln.fire(); + } + + function testFireUniv3HigherYenAllowsUniv2LowerZenAllows() public { // get ref price uint256 ref = getRefOutAMount(20_000 * WAD); console.log("ref: %s", ref); @@ -411,23 +461,83 @@ contract KilnTest is Test { // set yen to 1.00 kiln.file("yen", 100 * WAD / 100); - // drive up univ2 out amount - changeUniv2Price(20_000 * WAD, ref, true); + // change univ2 out amount to be slightly lower than ref) + changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); - // set zen to 0.95 kiln.file("zen", 95 * WAD / 100); - // TODO: this currently fail - // since there's almost no liquidity need to make sure when driving price up/dpwn that the price will be very close to the ref - // also need to consider seeding with small amount of initial liquidity mintDai(address(kiln), 50_000 * WAD); kiln.fire(); // fire - // check success + assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); + assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); + assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); + } + + function testFireUniv3HigherYenAllowsUniv2LowerZenBlocks() public { + // get ref price + uint256 ref = getRefOutAMount(20_000 * WAD); + console.log("ref: %s", ref); + + // drive up univ3 out amount + changeUniv3Price(20_000 * WAD, ref, true); + + // set yen to 1.00 + kiln.file("yen", 100 * WAD / 100); + + // change univ2 out amount to be slightly lower than ref) + changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); + + kiln.file("zen", 1 * WAD); + + mintDai(address(kiln), 50_000 * WAD); + + vm.expectRevert("UniswapV2Router: INSUFFICIENT_B_AMOUNT"); + kiln.fire(); + } + + function testFireUniv3HigherYenBlocks() public { + // get ref price + uint256 ref = getRefOutAMount(20_000 * WAD); + console.log("ref: %s", ref); + + // drive up univ3 out amount + changeUniv3Price(20_000 * WAD, ref, true); + + kiln.file("yen", 105 * WAD / 100); + mintDai(address(kiln), 50_000 * WAD); + vm.expectRevert("Too little received"); + kiln.fire(); } + function testFireUniv3LowerYenAllowsUniv2HigherZenAllows() public { + // get ref price + uint256 ref = getRefOutAMount(20_000 * WAD); + console.log("ref: %s", ref); + + // drive down univ3 out amount + changeUniv3Price(20_000 * WAD, ref, true); + + // set yen to 1.00 + kiln.file("yen", 100 * WAD / 100); + + // change univ2 out amount to be slightly lower than ref) + changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); + + kiln.file("zen", 95 * WAD / 100); + + mintDai(address(kiln), 50_000 * WAD); + kiln.fire(); + + // fire + assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); + assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); + assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); + } + + function testFireAfterLowTwap() public {} function testFireAfterHighTwap() public {} From 011e739d8a080418881158e7217211b2aa69ebdf Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Wed, 22 Feb 2023 16:19:11 +0200 Subject: [PATCH 07/19] Testing - WIP --- src/Recipe2.sol | 37 ++- src/Recipe2.t.sol | 583 +++++++++++++++------------------------------- 2 files changed, 198 insertions(+), 422 deletions(-) diff --git a/src/Recipe2.sol b/src/Recipe2.sol index 60c955b..880d661 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -16,7 +16,6 @@ pragma solidity ^0.8.14; -import "forge-std/Test.sol"; // TODO: remove import {KilnBase, GemLike} from "./KilnBase.sol"; import {TwapProduct} from "./uniV3/TwapProduct.sol"; @@ -117,10 +116,10 @@ contract Recipe2 is KilnBase, TwapProduct { */ function file(bytes32 what, uint256 data) public override auth { if (what == "yen") { - require(yen > 0, "Recipe2/zero-yen"); + require(data > 0, "Recipe2/zero-yen"); yen = data; } else if (what == "zen") { - require(zen > 0, "Recipe2/zero-zen"); + require(data > 0, "Recipe2/zero-zen"); zen = data; } else if (what == "scope") { require(data > 0, "Recipe2/zero-scope"); @@ -133,8 +132,9 @@ contract Recipe2 is KilnBase, TwapProduct { emit File(what, data); } - struct Workspace { // TODO: consider moving - uint256 halfLot; + // TODO: try moving to regular stack vars, need to avoid stack too deep + struct Workspace { + uint256 halfIn; bytes path; uint256 yen; uint256 zen; @@ -142,45 +142,42 @@ contract Recipe2 is KilnBase, TwapProduct { uint256 bought; } - function _swap(uint256 lot) internal override returns (uint256 swapped) { + function _swap(uint256 inAmount) internal override returns (uint256 swapped) { Workspace memory ws = Workspace({ - halfLot: lot / 2, + halfIn: inAmount / 2, path: path, yen: yen, zen: zen, quote: 0, bought: 0 }); - ws.quote = quote(ws.path, ws.halfLot, uint32(scope)); + ws.quote = quote(ws.path, ws.halfIn, uint32(scope)); - GemLike(sell).approve(uniV3Router, ws.halfLot); + GemLike(sell).approve(uniV3Router, ws.halfIn); SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ path: ws.path, recipient: address(this), deadline: block.timestamp, - amountIn: ws.halfLot, + amountIn: ws.halfIn, amountOutMinimum: ws.quote * ws.yen / WAD }); ws.bought = SwapRouterLike(uniV3Router).exactInput(params); - console.log("halfLot %s bought %s price %s", ws.halfLot, ws.bought, ws.halfLot / ws.bought); // TODO: remove // In case the `sell` token deposit amount needs to be insisted on it means the full `bought` amount of buy tokens are deposited. - // Therefore we want at least the reference price (halfLot / quote) factored by zen. - uint256 sellDepositMin = (ws.bought * ws.halfLot / ws.quote) * ws.zen / WAD; + // Therefore we want at least the reference price (halfIn / quote) factored by zen. + uint256 sellDepositMin = (ws.bought * ws.halfIn / ws.quote) * ws.zen / WAD; - // In case the `buy` token deposit amount needs to be insisted on it means the full `halfLot` amount of sell tokens are deposited. + // In case the `buy` token deposit amount needs to be insisted on it means the full `halfIn` amount of sell tokens are deposited. // As `halflot` was also used in the quote calculation, it represents the exact reference price and only needs to be factored by zen uint256 buyDepositMin = ws.quote * ws.zen / WAD; - console.log("sellDepositMin %s buyDepositMin %s", sellDepositMin, buyDepositMin); // TODO: remove - - GemLike(sell).approve(uniV2Router, ws.halfLot); + GemLike(sell).approve(uniV2Router, ws.halfIn); GemLike(buy).approve(uniV2Router, ws.bought); - (uint256 amountA, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ // TODO: remove amountA as it is not needed + (, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ tokenA: sell, tokenB: buy, - amountADesired: ws.halfLot, + amountADesired: ws.halfIn, amountBDesired: ws.bought, amountAMin: sellDepositMin, amountBMin: buyDepositMin, @@ -188,12 +185,10 @@ contract Recipe2 is KilnBase, TwapProduct { deadline: block.timestamp }); swapped = liquidity; - console.log("dai deposited %s mkr deposited %s liquidity %s", amountA, amountB, liquidity); // TODO: remove // If not all buy tokens were used, send the remainder to the receiver if (ws.bought > amountB) { GemLike(buy).transfer(receiver, ws.bought - amountB); - console.log("sent remaining mkr %s ", ws.bought - amountB); // TODO: remove } } diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index 6937310..c8d5f63 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -27,14 +27,12 @@ interface TestGem { } // https://github.com/Uniswap/v3-periphery/blob/v1.0.0/contracts/lens/UNIV3Quoter.sol#L106-L122 -interface UNIV3Quoter { // TODO: handle caps - function quoteExactInput( - bytes calldata path, - uint256 amountIn - ) external returns (uint256 amountOut); +interface Univ3Quoter { + function quoteExactInput(bytes calldata path, uint256 amountIn) external returns (uint256 amountOut); } -interface ExtendedUNIV2Router is UniswapV2Router02Like { +// https://github.com/Uniswap/v2-periphery/blob/dda62473e2da448bc9cb8f4514dadda4aeede5f4/contracts/UniswapV2Router02.sol +interface ExtendedUni2Router is UniswapV2Router02Like { function factory() external view returns (address); function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) external returns (uint256 amountOut); function swapExactTokensForTokens( @@ -46,11 +44,13 @@ interface ExtendedUNIV2Router is UniswapV2Router02Like { ) external returns (uint[] memory amounts); } +// https://github.com/Uniswap/v2-core/blob/ee547b17853e71ed4e0101ccfd52e70d5acded58/contracts/UniswapV2Factory.sol interface UniswapV2FactoryLike { function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); } +// https://github.com/Uniswap/v2-core/blob/ee547b17853e71ed4e0101ccfd52e70d5acded58/contracts/UniswapV2Pair.sol interface UniswapV2PairLike { function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); } @@ -62,9 +62,10 @@ contract KilnTest is Test { using UniswapV2Library for *; Recipe2 kiln; - UNIV3Quoter univ3Quoter; + Univ3Quoter univ3Quoter; User user; + address pairToken; bytes path; address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; @@ -84,41 +85,32 @@ contract KilnTest is Test { event File(bytes32 indexed what, bytes data); event File(bytes32 indexed what, uint256 data); + // TODO: need to start with less liquidity but deposit in the right price (can use existing fucstion but with loss resolution trades) + // TODO: check the order DAI/MKR in the pair + function setUp() public { user = new User(); path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); kiln = new Recipe2(DAI, MKR, UNIV2ROUTER, UNIV3ROUTER, address(user)); - univ3Quoter = UNIV3Quoter(UNIV3QUOTER); + univ3Quoter = Univ3Quoter(UNIV3QUOTER); + pairToken = UniswapV2Library.pairFor(ExtendedUni2Router(UNIV2ROUTER).factory(), DAI, MKR); kiln.file("lot", 15_000 * WAD); kiln.file("hop", 6 hours); kiln.file("path", path); - kiln.file("yen", 50 * WAD / 100); // Insist on very little on default - kiln.file("zen", 50 * WAD / 100); // Allow large deviations by default - topUpLiquidity(); } - - function mintDai(address usr, uint256 amt) internal { - deal(DAI, usr, amt); - assertEq(GemLike(DAI).balanceOf(address(usr)), amt); - } - - // TODO: need to start initial liquidity more wisely - first change price, then deposit smaller amount, then start with small lot - // let's first assume we use topUpLiquidity for 1M dai, then worry about the ramp up - function topUpLiquidity() internal { uint256 daiAmt = 1_000_000 * WAD; uint256 mkrAmt = 1000 * WAD; uint reserveA; uint reserveB; - (address token0,) = UniswapV2Library.sortTokens(DAI, MKR); - address pairToken = UniswapV2Library.pairFor(ExtendedUNIV2Router(UNIV2ROUTER).factory(), DAI, MKR); + (address token0,) = UniswapV2Library.sortTokens(DAI, MKR); (uint reserve0, uint reserve1,) = UniswapV2PairLike(pairToken).getReserves(); (reserveA, reserveB) = DAI == token0 ? (reserve0, reserve1) : (reserve1, reserve0); @@ -143,169 +135,31 @@ contract KilnTest is Test { assertGt(GemLike(pairToken).balanceOf(address(this)), 0); } - /* - function estimate(uint256 amtIn) internal returns (uint256 amtOut) { - return univ3Quoter.quoteExactInput(path, amtIn); - } - - function swap(address gem, uint256 amount) internal { - GemLike(gem).approve(kiln.uniV3Router(), amount); - - bytes memory _path; - if (gem == DAI) { - _path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); - } else { - _path = abi.encodePacked(MKR, uint24(3000), WETH, uint24(500), USDC, uint24(100), DAI); - } - - ExactInputParams memory params = ExactInputParams( - _path, - address(this), // recipient - block.timestamp, // deadline - amount, // amountIn - 0 // amountOutMinimum - ); - - SwapRouterLike(kiln.uniV3Router()).exactInput(params); - } - */ - - - - - - - /* - function testFilePath() public { - path = abi.encodePacked(DAI, uint24(100), USDC); - vm.expectEmit(true, true, false, false); - emit File(bytes32("path"), path); - kiln.file("path", path); - assertEq0(kiln.path(), path); - } - - function testFileYen() public { - vm.expectEmit(true, true, false, false); - emit File(bytes32("yen"), 42); - kiln.file("yen", 42); - assertEq(kiln.yen(), 42); - } - - function testFileScope() public { - vm.expectEmit(true, true, false, false); - emit File(bytes32("scope"), 314); - kiln.file("scope", 314); - assertEq(kiln.scope(), 314); - } - - function testFileZeroScope() public { - vm.expectRevert("KilnUniV3/zero-scope"); - kiln.file("scope", 0); - } - - function testFileScopeTooLarge() public { - vm.expectRevert("KilnUniV3/scope-overflow"); - kiln.file("scope", uint32(type(int32).max) + 1); - } - - function testFileBytesUnrecognized() public { - vm.expectRevert("KilnUniV3/file-unrecognized-param"); - kiln.file("nonsense", bytes("")); - } - - function testFileUintUnrecognized() public { - vm.expectRevert("KilnBase/file-unrecognized-param"); - kiln.file("nonsense", 23); - } - - function testFilePathNonAuthed() public { - vm.startPrank(address(123)); - vm.expectRevert("KilnBase/not-authorized"); - kiln.file("path", path); - } - - function testFileYenNonAuthed() public { - vm.startPrank(address(123)); - vm.expectRevert("KilnBase/not-authorized"); - kiln.file("yen", 42); - } - - function testFileScopeNonAuthed() public { - vm.startPrank(address(123)); - vm.expectRevert("KilnBase/not-authorized"); - kiln.file("scope", 413); - } - */ - - function testFire1() public { // TODO: can remove once we have the other tests? - mintDai(address(kiln), 50_000 * WAD); - - assertEq(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); - assertEq(GemLike(DAI).balanceOf(address(kiln)),50_000 * WAD); - - kiln.fire(); - - assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); - assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); - assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); - } - - - /* - - Given a reference TWAP out amount, we want to test the following scenarios. - Note that `Higher` stands for higher out amount than the reference, while `Lower` stands for lower out amount than the reference. - - testFire - ├── Univ3Higher - │ ├── YenAllows (1.00) - │ │ ├── Univ2Higher - │ │ │ ├── ZenAllows (0.95) V - │ │ │ └── ZenBlocks (1.0) V - │ │ └── Univ2Lower - │ │ ├── ZenAllows (0.95) V - │ │ └── ZenBlocks (1.00) V - │ └── YenBlocks (1.05) - └── Univ3Lower - ├── YenAllows (0.95) - │ ├── Univ2Higher - │ │ ├── ZenAllows (0.95) - │ │ └── ZenBlocks (1.00) - │ └── Univ2Lower - │ ├── ZenAllows (0.95) - │ └── ZenBlocks (1.00) - └── YenBlocks (1.00) - */ - - - function getRefOutAMount(uint256 amountIn) internal returns (uint256) { + function getRefOutAMount(uint256 amountIn) internal view returns (uint256) { return kiln.quote(kiln.path(), amountIn, uint32(kiln.scope())); } function changeUniv3Price(uint256 amountIn, uint256 refOutAmount, bool reachHigher) internal { uint256 current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("current: %s", current); + // console.log("current: %s", current); - // TODO: change actor who does this to address(123) or something if (reachHigher) { while (current < refOutAmount) { - // trade mkr to dai uint256 mkrAmount = 20 * WAD; - bytes memory path_ = abi.encodePacked(MKR, uint24(3000), WETH, uint24(500), USDC, uint24(100), DAI); deal(MKR, address(this), mkrAmount); GemLike(MKR).approve(UNIV3ROUTER, mkrAmount); - SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ - path: path_, - recipient: address(this), - deadline: block.timestamp, - amountIn: mkrAmount, - amountOutMinimum: 0 + SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ + path: abi.encodePacked(MKR, uint24(3000), WETH, uint24(500), USDC, uint24(100), DAI), + recipient: address(this), + deadline: block.timestamp, + amountIn: mkrAmount, + amountOutMinimum: 0 }); SwapRouterLike(UNIV3ROUTER).exactInput(params); current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("current: %s", current); + // console.log("current: %s", current); } } else { while (current > refOutAmount) { @@ -315,7 +169,7 @@ contract KilnTest is Test { deal(DAI, address(this), daiAmount); GemLike(DAI).approve(UNIV3ROUTER, daiAmount); SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ - path: path, + path: abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR), recipient: address(this), deadline: block.timestamp, amountIn: daiAmount, @@ -324,7 +178,7 @@ contract KilnTest is Test { SwapRouterLike(UNIV3ROUTER).exactInput(params); current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("current: %s", current); + // console.log("current: %s", current); } } } @@ -334,29 +188,18 @@ contract KilnTest is Test { uint reserveB; (address token0,) = UniswapV2Library.sortTokens(DAI, MKR); - address pairToken = UniswapV2Library.pairFor( ExtendedUNIV2Router(UNIV2ROUTER).factory(), DAI, MKR); - - console.log("pairToken: %s", pairToken); (uint reserve0, uint reserve1,) = UniswapV2PairLike(pairToken).getReserves(); (reserveA, reserveB) = DAI == token0 ? (reserve0, reserve1) : (reserve1, reserve0); - console.log("reserveA: %s", reserveA); - console.log("reserveB: %s", reserveB); - - amountOut = ExtendedUNIV2Router(UNIV2ROUTER).getAmountOut(amountIn, reserveA, reserveB); + amountOut = ExtendedUni2Router(UNIV2ROUTER).getAmountOut(amountIn, reserveA, reserveB); } function changeUniv2Price(uint256 amountIn, uint256 minOutAmount, uint256 maxOutAMount) internal { - uint256 current = getUniv2AmountOut(amountIn); - console.log("", minOutAmount); - console.log("minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); - // TODO: change actor who does this to address(123) or something while (current < minOutAmount) { - // trade mkr to dai - address[] memory _path = new address[](2); _path[0] = MKR; _path[1] = DAI; @@ -364,8 +207,8 @@ contract KilnTest is Test { uint256 mkrAmount = 1 * WAD / 10; deal(MKR, address(this), mkrAmount); GemLike(MKR).approve(UNIV2ROUTER, mkrAmount); - ExtendedUNIV2Router(UNIV2ROUTER).swapExactTokensForTokens( - mkrAmount, // amountIn + ExtendedUni2Router(UNIV2ROUTER).swapExactTokensForTokens( + mkrAmount, // amountIn 0, // amountOutMin _path, // path address(this), // to @@ -373,7 +216,7 @@ contract KilnTest is Test { ); // deadline current = getUniv2AmountOut(amountIn); - console.log("driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } while (current > maxOutAMount) { @@ -385,7 +228,7 @@ contract KilnTest is Test { uint256 daiAmount = 1000 * WAD; deal(DAI, address(this), daiAmount); GemLike(DAI).approve(UNIV2ROUTER, daiAmount); - ExtendedUNIV2Router(UNIV2ROUTER).swapExactTokensForTokens( + ExtendedUni2Router(UNIV2ROUTER).swapExactTokensForTokens( daiAmount, // amountIn 0, // amountOutMin _path, // path @@ -394,331 +237,269 @@ contract KilnTest is Test { ); // deadline current = getUniv2AmountOut(amountIn); - console.log("driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } assert(current >= minOutAmount && current <= maxOutAMount); + } + function testFilePath() public { + path = abi.encodePacked(DAI, uint24(100), USDC); + vm.expectEmit(true, true, false, false); + emit File(bytes32("path"), path); + kiln.file("path", path); + assertEq0(kiln.path(), path); } + function testFileYen() public { + vm.expectEmit(true, true, false, false); + emit File(bytes32("yen"), 42); + kiln.file("yen", 42); + assertEq(kiln.yen(), 42); + } + + function testFileZen() public { + vm.expectEmit(true, true, false, false); + emit File(bytes32("zen"), 7); + kiln.file("zen", 7); + assertEq(kiln.zen(), 7); + } + + function testFileYenZero() public { + vm.expectRevert("Recipe2/zero-yen"); + kiln.file("yen", 0); + } + + function testFileZenZero() public { + vm.expectRevert("Recipe2/zero-zen"); + kiln.file("zen", 0); + } + + function testFileScope() public { + vm.expectEmit(true, true, false, false); + emit File(bytes32("scope"), 314); + kiln.file("scope", 314); + assertEq(kiln.scope(), 314); + } + + function testFileZeroScope() public { + vm.expectRevert("Recipe2/zero-scope"); + kiln.file("scope", 0); + } + + function testFileScopeTooLarge() public { + vm.expectRevert("Recipe2/scope-overflow"); + kiln.file("scope", uint32(type(int32).max) + 1); + } + + function testFileBytesUnrecognized() public { + vm.expectRevert("Recipe2/file-unrecognized-param"); + kiln.file("nonsense", bytes("")); + } + + function testFileUintUnrecognized() public { + vm.expectRevert("KilnBase/file-unrecognized-param"); + kiln.file("nonsense", 23); + } + + function testFilePathNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.file("path", path); + } + + function testFileYenNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.file("yen", 42); + } + + function testFileZenNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.file("zen", 7); + } + + function testFileScopeNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.file("scope", 413); + } + + /* + Given a reference TWAP out amount, we want to test the following scenarios. + Note that `Higher` stands for higher out amount than the reference, while `Lower` stands for lower out amount + than the reference. + + When Univ3 out amount is higher than the reference a yen of 100% should allow it, and we assume 105% blocks it. + When Univ3 out amount is lower than the reference we assume a yen of 95% should allow it, and 100% blocks it. + When Univ2 out amount is either lower or higher a zen of 95% should allow it, and 100% blocks it. + + testFire + ├── Univ3Higher + │ ├── YenAllows (1.00) + │ │ ├── Univ2Higher + │ │ │ ├── ZenAllows (0.95) + │ │ │ └── ZenBlocks (1.0) + │ │ └── Univ2Lower + │ │ ├── ZenAllows (0.95) + │ │ └── ZenBlocks (1.00) + │ └── YenBlocks (1.05) V + └── Univ3Lower + ├── YenAllows (0.95) + │ ├── Univ2Higher + │ │ ├── ZenAllows (0.95) + │ │ └── ZenBlocks (1.00) + │ └── Univ2Lower + │ ├── ZenAllows (0.95) + │ └── ZenBlocks (1.00) + └── YenBlocks (1.00) + */ function testFireUniv3HigherYenAllowsUniv2HigherZenAllows() public { - // get ref price uint256 ref = getRefOutAMount(20_000 * WAD); - console.log("ref: %s", ref); - // drive up univ3 out amount changeUniv3Price(20_000 * WAD, ref, true); - - // set yen to 1.00 kiln.file("yen", 100 * WAD / 100); - // change univ2 out amount to be slightly higher than ref changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); - - // allow 5% margin in uniV2 to either side kiln.file("zen", 95 * WAD / 100); - mintDai(address(kiln), 50_000 * WAD); + deal(DAI, address(kiln), 50_000 * WAD); kiln.fire(); - // fire assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); } function testFireUniv3HigherYenAllowsUniv2HigherZenBlocks() public { - // get ref price uint256 ref = getRefOutAMount(20_000 * WAD); - console.log("ref: %s", ref); - // drive up univ3 out amount changeUniv3Price(20_000 * WAD, ref, true); - - // set yen to 1.00 kiln.file("yen", 100 * WAD / 100); - // change univ2 out amount to be slightly higher than ref changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); - kiln.file("zen", 1 * WAD); - mintDai(address(kiln), 50_000 * WAD); - + deal(DAI, address(kiln), 50_000 * WAD); vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); kiln.fire(); } function testFireUniv3HigherYenAllowsUniv2LowerZenAllows() public { - // get ref price uint256 ref = getRefOutAMount(20_000 * WAD); - console.log("ref: %s", ref); - // drive up univ3 out amount changeUniv3Price(20_000 * WAD, ref, true); - - // set yen to 1.00 kiln.file("yen", 100 * WAD / 100); - // change univ2 out amount to be slightly lower than ref) changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); - kiln.file("zen", 95 * WAD / 100); - mintDai(address(kiln), 50_000 * WAD); + deal(DAI, address(kiln), 50_000 * WAD); kiln.fire(); - // fire assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); } function testFireUniv3HigherYenAllowsUniv2LowerZenBlocks() public { - // get ref price uint256 ref = getRefOutAMount(20_000 * WAD); - console.log("ref: %s", ref); - // drive up univ3 out amount changeUniv3Price(20_000 * WAD, ref, true); - - // set yen to 1.00 kiln.file("yen", 100 * WAD / 100); - // change univ2 out amount to be slightly lower than ref) changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); - kiln.file("zen", 1 * WAD); - mintDai(address(kiln), 50_000 * WAD); - + deal(DAI, address(kiln), 50_000 * WAD); vm.expectRevert("UniswapV2Router: INSUFFICIENT_B_AMOUNT"); kiln.fire(); } function testFireUniv3HigherYenBlocks() public { - // get ref price uint256 ref = getRefOutAMount(20_000 * WAD); - console.log("ref: %s", ref); - // drive up univ3 out amount changeUniv3Price(20_000 * WAD, ref, true); - kiln.file("yen", 105 * WAD / 100); - mintDai(address(kiln), 50_000 * WAD); + deal(DAI, address(kiln), 50_000 * WAD); vm.expectRevert("Too little received"); kiln.fire(); } function testFireUniv3LowerYenAllowsUniv2HigherZenAllows() public { - // get ref price uint256 ref = getRefOutAMount(20_000 * WAD); - console.log("ref: %s", ref); - - // drive down univ3 out amount - changeUniv3Price(20_000 * WAD, ref, true); - - // set yen to 1.00 - kiln.file("yen", 100 * WAD / 100); - // change univ2 out amount to be slightly lower than ref) - changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); + changeUniv3Price(20_000 * WAD, ref, false); + kiln.file("yen", 95 * WAD / 100); + changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); kiln.file("zen", 95 * WAD / 100); - mintDai(address(kiln), 50_000 * WAD); + deal(DAI, address(kiln), 50_000 * WAD); kiln.fire(); - // fire assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); } + function testFireUniv3LowerYenAllowsUniv2HigherZenBlocks() public { + uint256 ref = getRefOutAMount(20_000 * WAD); - function testFireAfterLowTwap() public {} - function testFireAfterHighTwap() public {} - - - - /* - function testFireYenMuchLessThanTwap() public { - mintDai(address(kiln), 50_000 * WAD); - - assertEq(GemLike(DAI).balanceOf(address(kiln)), 50_000 * WAD); - uint256 mkrSupply = TestGem(MKR).totalSupply(); - assertTrue(mkrSupply > 0); - - uint256 _est = estimate(50_000 * WAD); - assertTrue(_est > 0); - - assertEq(GemLike(MKR).balanceOf(address(user)), 0); - - kiln.file("yen", 80 * WAD / 100); - kiln.fire(); - - assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); - assertEq(GemLike(MKR).balanceOf(address(user)), _est); - } - - function testFireYenMuchMoreThanTwap() public { - mintDai(address(kiln), 50_000 * WAD); - - assertEq(GemLike(DAI).balanceOf(address(kiln)), 50_000 * WAD); - uint256 mkrSupply = TestGem(MKR).totalSupply(); - assertTrue(mkrSupply > 0); - - uint256 _est = estimate(50_000 * WAD); - assertTrue(_est > 0); + changeUniv3Price(20_000 * WAD, ref, false); + kiln.file("yen", 95 * WAD / 100); - assertEq(GemLike(MKR).balanceOf(address(user)), 0); + changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); + kiln.file("zen", 1 * WAD); - kiln.file("yen", 120 * WAD / 100); - // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol#L165 - vm.expectRevert("Too little received"); + deal(DAI, address(kiln), 50_000 * WAD); + vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); kiln.fire(); } - function testFireYenZero() public { - mintDai(address(kiln), 50_000 * WAD); - - assertEq(GemLike(DAI).balanceOf(address(kiln)), 50_000 * WAD); - uint256 mkrSupply = TestGem(MKR).totalSupply(); - assertTrue(mkrSupply > 0); + function testFireUniv3LowerYenAllowsUniv2LowerZenAllows() public { + uint256 ref = getRefOutAMount(20_000 * WAD); - uint256 _est = estimate(50_000 * WAD); - assertTrue(_est > 0); + changeUniv3Price(20_000 * WAD, ref, false); + kiln.file("yen", 95 * WAD / 100); - assertEq(GemLike(MKR).balanceOf(address(user)), 0); + changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); + kiln.file("zen", 95 * WAD / 100); - kiln.file("yen", 0); + deal(DAI, address(kiln), 50_000 * WAD); kiln.fire(); + assertGt(GemLike(UNIV2DAIMKRLP).balanceOf(address(user)), 0); assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 50_000 * WAD); - assertEq(GemLike(MKR).balanceOf(address(user)), _est); - } - - // Lot is 50k, ensure we can still fire if balance is lower than lot - function testFireLtLot() public { - mintDai(address(kiln), 20_000 * WAD); - - assertEq(GemLike(DAI).balanceOf(address(kiln)), 20_000 * WAD); - uint256 mkrSupply = TestGem(MKR).totalSupply(); - assertTrue(mkrSupply > 0); - - uint256 _est = estimate(20_000 * WAD); - assertTrue(_est > 0); - - assertEq(GemLike(MKR).balanceOf(address(user)), 0); - - kiln.fire(); - - assertEq(GemLike(DAI).balanceOf(address(kiln)), 0); - assertEq(TestGem(MKR).totalSupply(), mkrSupply); - assertEq(GemLike(MKR).balanceOf(address(user)), _est); - } - - // Ensure we only sell off the lot size - function testFireGtLot() public { - mintDai(address(kiln), 100_000 * WAD); - - assertEq(GemLike(DAI).balanceOf(address(kiln)), 100_000 * WAD); - - uint256 _est = estimate(kiln.lot()); - assertTrue(_est > 0); - - kiln.fire(); - - // Due to liquidity constrants, not all of the tokens may be sold - assertTrue(GemLike(DAI).balanceOf(address(kiln)) >= 50_000 * WAD); - assertTrue(GemLike(DAI).balanceOf(address(kiln)) < 100_000 * WAD); - assertEq(GemLike(MKR).balanceOf(address(user)), _est); - } - - function testFireMulti() public { - mintDai(address(kiln), 100_000 * WAD); - - kiln.file("lot", 50 * WAD); // Use a smaller amount due to slippage limits - - kiln.fire(); - - skip(6 hours); - - kiln.fire(); + assertEq(GemLike(MKR).balanceOf(address(kiln)), 0); } - function testFireAfterLowTwap() public { - mintDai(address(this), 11_000_000 * WAD); // funds for manipulating prices - mintDai(address(kiln), 1_000_000 * WAD); - - kiln.file("hop", 0 hours); // for convenience allow firing right away - kiln.file("scope", 1 hours); - kiln.file("yen", 120 * WAD / 100); // only swap if price rose by 20% vs twap - - uint256 mkrBefore = GemLike(MKR).balanceOf(address(this)); - - // drive down MKR out amount with big DAI->MKR swap - swap(DAI, 10_000_000 * WAD); - - // make sure twap measures low MKR out amount at the beginning of the hour (by making small swap) - vm.roll(block.number + 1); - swap(DAI, WAD / 100); - - // let 1 hour almost pass - skip(1 hours - 1 seconds); + function testFireUniv3LowerYenAllowsUniv2LowerZenBlocks() public { + uint256 ref = getRefOutAMount(20_000 * WAD); - // make sure twap measures low MKR out amount at the end of the hour (by making small swap) - vm.roll(block.number + 1); - swap(DAI, WAD / 100); + changeUniv3Price(20_000 * WAD, ref, false); + kiln.file("yen", 95 * WAD / 100); - // fire should fail for low MKR out amount - // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol#L165 - vm.expectRevert("Too little received"); - kiln.fire(); - - // drive MKR out amount back up - swap(MKR, GemLike(MKR).balanceOf(address(this)) - mkrBefore); + changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); + kiln.file("zen", 1 * WAD); - // fire should succeed after MKR amount rose vs twap + deal(DAI, address(kiln), 50_000 * WAD); + vm.expectRevert("UniswapV2Router: INSUFFICIENT_B_AMOUNT"); kiln.fire(); } - function testFireAfterHighTwap() public { - mintDai(address(this), 11_000_000 * WAD); // funds for manipulating prices - mintDai(address(kiln), 1_000_000 * WAD); - - kiln.file("hop", 0 hours); // for convenience allow firing right away - kiln.file("scope", 1 hours); - kiln.file("yen", 80 * WAD / 100); // allow swap even if price fell by 20% vs twap - - // make sure twap measures regular MKR out amount at the beginning of the hour (by making small swap) - vm.roll(block.number + 1); - swap(DAI, WAD / 100); - - // let 1 hour almost pass - skip(1 hours - 1 seconds); - - // make sure twap measures regular MKR out amount at the end of the hour (by making small swap) - vm.roll(block.number + 1); - swap(DAI, WAD / 100); - - // fire should succeed for low yen before any price manipulation - kiln.fire(); + function testFireUniv3LowerYenBlocks() public { + uint256 ref = getRefOutAMount(20_000 * WAD); - // drive down MKR out amount with big DAI->MKR swap - swap(DAI, 10_000_000 * WAD); + changeUniv3Price(20_000 * WAD, ref, false); + kiln.file("yen", 1 * WAD); - // fire should fail when low MKR amount - // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol#L165 + deal(DAI, address(kiln), 50_000 * WAD); vm.expectRevert("Too little received"); kiln.fire(); } - - function testFactoryDerivedFromRouter() public { - assertEq(SwapRouterLike(UNIV3ROUTER).factory(), UNIV3FACTORY); - } - */ } From d457d61144c1084fab0ea047fcb20e9e4090c6d1 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Thu, 23 Feb 2023 14:05:22 +0200 Subject: [PATCH 08/19] Recipe2 tests - getting there.. --- src/Recipe2.sol | 4 +- src/Recipe2.t.sol | 148 ++++++++++++++++++---------------------------- 2 files changed, 59 insertions(+), 93 deletions(-) diff --git a/src/Recipe2.sol b/src/Recipe2.sol index 880d661..de56d10 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -16,6 +16,7 @@ pragma solidity ^0.8.14; +import "forge-std/Test.sol"; // TODO: remove import {KilnBase, GemLike} from "./KilnBase.sol"; import {TwapProduct} from "./uniV3/TwapProduct.sol"; @@ -163,6 +164,7 @@ contract Recipe2 is KilnBase, TwapProduct { amountOutMinimum: ws.quote * ws.yen / WAD }); ws.bought = SwapRouterLike(uniV3Router).exactInput(params); + console.log("ws.halfIn %s, bought %s", ws.halfIn, ws.bought); // In case the `sell` token deposit amount needs to be insisted on it means the full `bought` amount of buy tokens are deposited. // Therefore we want at least the reference price (halfIn / quote) factored by zen. @@ -171,7 +173,7 @@ contract Recipe2 is KilnBase, TwapProduct { // In case the `buy` token deposit amount needs to be insisted on it means the full `halfIn` amount of sell tokens are deposited. // As `halflot` was also used in the quote calculation, it represents the exact reference price and only needs to be factored by zen uint256 buyDepositMin = ws.quote * ws.zen / WAD; - + console.log("sellDepositMin %s, buyDepositMin %s", sellDepositMin, buyDepositMin); GemLike(sell).approve(uniV2Router, ws.halfIn); GemLike(buy).approve(uniV2Router, ws.bought); (, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index c8d5f63..775146a 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -65,6 +65,10 @@ contract KilnTest is Test { Univ3Quoter univ3Quoter; User user; + uint256 halfLot; + uint256 refOneWad; + uint256 refHalfLot; + address pairToken; bytes path; @@ -85,9 +89,6 @@ contract KilnTest is Test { event File(bytes32 indexed what, bytes data); event File(bytes32 indexed what, uint256 data); - // TODO: need to start with less liquidity but deposit in the right price (can use existing fucstion but with loss resolution trades) - // TODO: check the order DAI/MKR in the pair - function setUp() public { user = new User(); path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); @@ -99,40 +100,19 @@ contract KilnTest is Test { kiln.file("lot", 15_000 * WAD); kiln.file("hop", 6 hours); kiln.file("path", path); + halfLot = kiln.lot() / 2; - topUpLiquidity(); - } - - function topUpLiquidity() internal { - uint256 daiAmt = 1_000_000 * WAD; - uint256 mkrAmt = 1000 * WAD; - - uint reserveA; - uint reserveB; - - (address token0,) = UniswapV2Library.sortTokens(DAI, MKR); - (uint reserve0, uint reserve1,) = UniswapV2PairLike(pairToken).getReserves(); - (reserveA, reserveB) = DAI == token0 ? (reserve0, reserve1) : (reserve1, reserve0); - - mkrAmt = daiAmt / (reserveA / reserveB) - 10 * WAD; - - deal(DAI, address(this), daiAmt); - deal(MKR, address(this), mkrAmt); - - GemLike(DAI).approve(UNIV2ROUTER, daiAmt); - GemLike(MKR).approve(UNIV2ROUTER, mkrAmt); + // When changing univ3 price we'll have to relate to half lot amount, as that's what fire() trades there + refHalfLot = getRefOutAMount(halfLot); + console.log("refHalfLot: %s", refHalfLot); - UniswapV2Router02Like(UNIV2ROUTER).addLiquidity( - MKR, - DAI, - mkrAmt, - daiAmt, - 0, - 0, - address(this), - block.timestamp); + // When changing univ2 price we'll use one WAD as reference fire only deposit theres (no price change) + refOneWad = getRefOutAMount(WAD); - assertGt(GemLike(pairToken).balanceOf(address(this)), 0); + // Bootstrapping - + // As there's almost no initial liquidity in v2, need to arb the price then deposit a reasonable amount + // As these are small amounts involved the assumption is that it will happen separately from kiln + changeUniv2Price(WAD, refOneWad * 995 / 1000, refOneWad * 1005 / 1000); } function getRefOutAMount(uint256 amountIn) internal view returns (uint256) { @@ -141,7 +121,7 @@ contract KilnTest is Test { function changeUniv3Price(uint256 amountIn, uint256 refOutAmount, bool reachHigher) internal { uint256 current = univ3Quoter.quoteExactInput(path, amountIn); - // console.log("current: %s", current); + console.log("changeUniv3Price current: %s", current); if (reachHigher) { while (current < refOutAmount) { @@ -159,7 +139,7 @@ contract KilnTest is Test { SwapRouterLike(UNIV3ROUTER).exactInput(params); current = univ3Quoter.quoteExactInput(path, amountIn); - // console.log("current: %s", current); + console.log("univ3 reach higher current: %s refOutAmount: %s", current, refOutAmount); } } else { while (current > refOutAmount) { @@ -178,7 +158,7 @@ contract KilnTest is Test { SwapRouterLike(UNIV3ROUTER).exactInput(params); current = univ3Quoter.quoteExactInput(path, amountIn); - // console.log("current: %s", current); + console.log("univ3 reach lower current: %s refOutAmount: %s", current, refOutAmount); } } } @@ -196,7 +176,7 @@ contract KilnTest is Test { function changeUniv2Price(uint256 amountIn, uint256 minOutAmount, uint256 maxOutAMount) internal { uint256 current = getUniv2AmountOut(amountIn); - // console.log("minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + console.log("univ2 minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); while (current < minOutAmount) { @@ -204,7 +184,7 @@ contract KilnTest is Test { _path[0] = MKR; _path[1] = DAI; - uint256 mkrAmount = 1 * WAD / 10; + uint256 mkrAmount = WAD / 10000; deal(MKR, address(this), mkrAmount); GemLike(MKR).approve(UNIV2ROUTER, mkrAmount); ExtendedUni2Router(UNIV2ROUTER).swapExactTokensForTokens( @@ -216,7 +196,7 @@ contract KilnTest is Test { ); // deadline current = getUniv2AmountOut(amountIn); - // console.log("driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + console.log("univ2 driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } while (current > maxOutAMount) { @@ -225,7 +205,7 @@ contract KilnTest is Test { _path[0] = DAI; _path[1] = MKR; - uint256 daiAmount = 1000 * WAD; + uint256 daiAmount = 1 * WAD / 10; deal(DAI, address(this), daiAmount); GemLike(DAI).approve(UNIV2ROUTER, daiAmount); ExtendedUni2Router(UNIV2ROUTER).swapExactTokensForTokens( @@ -237,7 +217,7 @@ contract KilnTest is Test { ); // deadline current = getUniv2AmountOut(amountIn); - // console.log("driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + console.log("univ2 driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } assert(current >= minOutAmount && current <= maxOutAMount); @@ -357,13 +337,11 @@ contract KilnTest is Test { */ function testFireUniv3HigherYenAllowsUniv2HigherZenAllows() public { - uint256 ref = getRefOutAMount(20_000 * WAD); - - changeUniv3Price(20_000 * WAD, ref, true); + changeUniv3Price(halfLot, refHalfLot, true); kiln.file("yen", 100 * WAD / 100); - changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); - kiln.file("zen", 95 * WAD / 100); + changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); + kiln.file("zen", 98 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); kiln.fire(); @@ -374,27 +352,26 @@ contract KilnTest is Test { } function testFireUniv3HigherYenAllowsUniv2HigherZenBlocks() public { - uint256 ref = getRefOutAMount(20_000 * WAD); - - changeUniv3Price(20_000 * WAD, ref, true); + changeUniv3Price(halfLot, refHalfLot, true); kiln.file("yen", 100 * WAD / 100); - changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); + changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); kiln.file("zen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); + + // Note that if both uniV2 min amounts don't suffice the revert is "INSUFFICIENT_A_AMOUNT" - + // https://github.com/Uniswap/v2-periphery/blob/master/contracts/UniswapV2Router02.sol#L56 vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); kiln.fire(); } function testFireUniv3HigherYenAllowsUniv2LowerZenAllows() public { - uint256 ref = getRefOutAMount(20_000 * WAD); - - changeUniv3Price(20_000 * WAD, ref, true); + changeUniv3Price(halfLot, refHalfLot, true); kiln.file("yen", 100 * WAD / 100); - changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); - kiln.file("zen", 95 * WAD / 100); + changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); + kiln.file("zen", 98 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); kiln.fire(); @@ -405,38 +382,33 @@ contract KilnTest is Test { } function testFireUniv3HigherYenAllowsUniv2LowerZenBlocks() public { - uint256 ref = getRefOutAMount(20_000 * WAD); - - changeUniv3Price(20_000 * WAD, ref, true); + changeUniv3Price(halfLot, refHalfLot, true); kiln.file("yen", 100 * WAD / 100); - changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); + changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); kiln.file("zen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); - vm.expectRevert("UniswapV2Router: INSUFFICIENT_B_AMOUNT"); + vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); kiln.fire(); } function testFireUniv3HigherYenBlocks() public { - uint256 ref = getRefOutAMount(20_000 * WAD); - - changeUniv3Price(20_000 * WAD, ref, true); - kiln.file("yen", 105 * WAD / 100); + changeUniv3Price(halfLot, refHalfLot, true); + kiln.file("yen", 102 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); vm.expectRevert("Too little received"); kiln.fire(); } + // this function testFireUniv3LowerYenAllowsUniv2HigherZenAllows() public { - uint256 ref = getRefOutAMount(20_000 * WAD); + changeUniv3Price(halfLot, refHalfLot, false); + kiln.file("yen", 98 * WAD / 100); - changeUniv3Price(20_000 * WAD, ref, false); - kiln.file("yen", 95 * WAD / 100); - - changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); - kiln.file("zen", 95 * WAD / 100); + changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); + kiln.file("zen", 98 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); kiln.fire(); @@ -448,12 +420,10 @@ contract KilnTest is Test { function testFireUniv3LowerYenAllowsUniv2HigherZenBlocks() public { - uint256 ref = getRefOutAMount(20_000 * WAD); - - changeUniv3Price(20_000 * WAD, ref, false); - kiln.file("yen", 95 * WAD / 100); + changeUniv3Price(halfLot, refHalfLot, false); + kiln.file("yen", 98 * WAD / 100); - changeUniv2Price(20_000 * WAD, ref, ref * 105 / 100); + changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); kiln.file("zen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); @@ -462,13 +432,11 @@ contract KilnTest is Test { } function testFireUniv3LowerYenAllowsUniv2LowerZenAllows() public { - uint256 ref = getRefOutAMount(20_000 * WAD); + changeUniv3Price(halfLot, refHalfLot, false); + kiln.file("yen", 98 * WAD / 100); - changeUniv3Price(20_000 * WAD, ref, false); - kiln.file("yen", 95 * WAD / 100); - - changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); - kiln.file("zen", 95 * WAD / 100); + changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); + kiln.file("zen", 98 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); kiln.fire(); @@ -479,23 +447,19 @@ contract KilnTest is Test { } function testFireUniv3LowerYenAllowsUniv2LowerZenBlocks() public { - uint256 ref = getRefOutAMount(20_000 * WAD); - - changeUniv3Price(20_000 * WAD, ref, false); - kiln.file("yen", 95 * WAD / 100); + changeUniv3Price(halfLot, refHalfLot, false); + kiln.file("yen", 98 * WAD / 100); - changeUniv2Price(20_000 * WAD, ref * 95 / 100, ref); - kiln.file("zen", 1 * WAD); + changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); + kiln.file("zen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); - vm.expectRevert("UniswapV2Router: INSUFFICIENT_B_AMOUNT"); + vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); kiln.fire(); } function testFireUniv3LowerYenBlocks() public { - uint256 ref = getRefOutAMount(20_000 * WAD); - - changeUniv3Price(20_000 * WAD, ref, false); + changeUniv3Price(halfLot, refHalfLot, false); kiln.file("yen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); From e156c4c42ed9b675bf90a112ed9351ada982dde6 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Thu, 23 Feb 2023 14:24:36 +0200 Subject: [PATCH 09/19] Unite structure of price change functions --- src/Recipe2.t.sol | 77 +++++++++++++++++++++++------------------------ 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index 775146a..f5b60d4 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -119,48 +119,47 @@ contract KilnTest is Test { return kiln.quote(kiln.path(), amountIn, uint32(kiln.scope())); } - function changeUniv3Price(uint256 amountIn, uint256 refOutAmount, bool reachHigher) internal { + function changeUniv3Price(uint256 amountIn, uint256 minOutAmount, uint256 maxOutAMount) internal { uint256 current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("changeUniv3Price current: %s", current); + console.log("univ3 minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); - if (reachHigher) { - while (current < refOutAmount) { + while (current < minOutAmount) { - uint256 mkrAmount = 20 * WAD; - deal(MKR, address(this), mkrAmount); - GemLike(MKR).approve(UNIV3ROUTER, mkrAmount); - SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ + uint256 mkrAmount = 20 * WAD; + deal(MKR, address(this), mkrAmount); + GemLike(MKR).approve(UNIV3ROUTER, mkrAmount); + SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ path: abi.encodePacked(MKR, uint24(3000), WETH, uint24(500), USDC, uint24(100), DAI), recipient: address(this), deadline: block.timestamp, amountIn: mkrAmount, amountOutMinimum: 0 - }); - SwapRouterLike(UNIV3ROUTER).exactInput(params); - - current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("univ3 reach higher current: %s refOutAmount: %s", current, refOutAmount); - } - } else { - while (current > refOutAmount) { - - // trade dai mkr - uint256 daiAmount = 20_000 * WAD; - deal(DAI, address(this), daiAmount); - GemLike(DAI).approve(UNIV3ROUTER, daiAmount); - SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ + }); + SwapRouterLike(UNIV3ROUTER).exactInput(params); + + current = univ3Quoter.quoteExactInput(path, amountIn); + console.log("univ3 driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + } + while (current > maxOutAMount) { + + // trade dai to mkr + uint256 daiAmount = 20_000 * WAD; + deal(DAI, address(this), daiAmount); + GemLike(DAI).approve(UNIV3ROUTER, daiAmount); + SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ path: abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR), recipient: address(this), deadline: block.timestamp, amountIn: daiAmount, amountOutMinimum: 0 - }); - SwapRouterLike(UNIV3ROUTER).exactInput(params); + }); + SwapRouterLike(UNIV3ROUTER).exactInput(params); - current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("univ3 reach lower current: %s refOutAmount: %s", current, refOutAmount); - } + current = univ3Quoter.quoteExactInput(path, amountIn); + console.log("univ3 driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } + + assert(current >= minOutAmount && current <= maxOutAMount); } function getUniv2AmountOut(uint256 amountIn) internal returns (uint256 amountOut) { @@ -196,7 +195,7 @@ contract KilnTest is Test { ); // deadline current = getUniv2AmountOut(amountIn); - console.log("univ2 driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + console.log("univ2 driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } while (current > maxOutAMount) { @@ -324,7 +323,7 @@ contract KilnTest is Test { │ │ └── Univ2Lower │ │ ├── ZenAllows (0.95) │ │ └── ZenBlocks (1.00) - │ └── YenBlocks (1.05) V + │ └── YenBlocks (1.05) └── Univ3Lower ├── YenAllows (0.95) │ ├── Univ2Higher @@ -337,7 +336,7 @@ contract KilnTest is Test { */ function testFireUniv3HigherYenAllowsUniv2HigherZenAllows() public { - changeUniv3Price(halfLot, refHalfLot, true); + changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 100 * WAD / 100); changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); @@ -352,7 +351,7 @@ contract KilnTest is Test { } function testFireUniv3HigherYenAllowsUniv2HigherZenBlocks() public { - changeUniv3Price(halfLot, refHalfLot, true); + changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 100 * WAD / 100); changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); @@ -367,7 +366,7 @@ contract KilnTest is Test { } function testFireUniv3HigherYenAllowsUniv2LowerZenAllows() public { - changeUniv3Price(halfLot, refHalfLot, true); + changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 100 * WAD / 100); changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); @@ -382,7 +381,7 @@ contract KilnTest is Test { } function testFireUniv3HigherYenAllowsUniv2LowerZenBlocks() public { - changeUniv3Price(halfLot, refHalfLot, true); + changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 100 * WAD / 100); changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); @@ -394,7 +393,7 @@ contract KilnTest is Test { } function testFireUniv3HigherYenBlocks() public { - changeUniv3Price(halfLot, refHalfLot, true); + changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 102 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); @@ -404,7 +403,7 @@ contract KilnTest is Test { // this function testFireUniv3LowerYenAllowsUniv2HigherZenAllows() public { - changeUniv3Price(halfLot, refHalfLot, false); + changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 98 * WAD / 100); changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); @@ -420,7 +419,7 @@ contract KilnTest is Test { function testFireUniv3LowerYenAllowsUniv2HigherZenBlocks() public { - changeUniv3Price(halfLot, refHalfLot, false); + changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 98 * WAD / 100); changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); @@ -432,7 +431,7 @@ contract KilnTest is Test { } function testFireUniv3LowerYenAllowsUniv2LowerZenAllows() public { - changeUniv3Price(halfLot, refHalfLot, false); + changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 98 * WAD / 100); changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); @@ -447,7 +446,7 @@ contract KilnTest is Test { } function testFireUniv3LowerYenAllowsUniv2LowerZenBlocks() public { - changeUniv3Price(halfLot, refHalfLot, false); + changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 98 * WAD / 100); changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); @@ -459,7 +458,7 @@ contract KilnTest is Test { } function testFireUniv3LowerYenBlocks() public { - changeUniv3Price(halfLot, refHalfLot, false); + changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); From 3e4a7aa4ee0ac1ab60ca8848684643b7507c7ab6 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Thu, 23 Feb 2023 15:17:04 +0200 Subject: [PATCH 10/19] Fit all local vars to stack --- src/Recipe2.sol | 63 +++++++++++++++++------------------------------ src/Recipe2.t.sol | 14 +++++------ 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/Recipe2.sol b/src/Recipe2.sol index de56d10..82cbe23 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -16,7 +16,6 @@ pragma solidity ^0.8.14; -import "forge-std/Test.sol"; // TODO: remove import {KilnBase, GemLike} from "./KilnBase.sol"; import {TwapProduct} from "./uniV3/TwapProduct.sol"; @@ -65,11 +64,11 @@ contract Recipe2 is KilnBase, TwapProduct { event File(bytes32 indexed what, bytes data); // @notice initialize a Uniswap V3 routing path contract - // @dev In order to complete fire has to trade on UniV3 and deposit to UniV2. With the initial constructor values, - // fire will trade on Univ3 only when the amount of tokens received is equal or better than the Univ3 - // 1 hour average price. - // It will then deposit to Univ2 only if the Univ2 price exactly matches the Univ3 TWAP price - // (unlikely, therefore at least zen should be reduced from default to support deviations to either direction). + // @dev In order to complete fire() has to trade on UniV3 and deposit to UniV2. With the initial constructor value of + // `yen` == WAD, fire will trade on Univ3 only when the amount of tokens received is equal or better than the Univ3 + // 1 hour average price (`reference price`). + // For the Univ2 deposit to work `zen` has to be reduced from the default value of WAD by the allowed + // divergence from the reference price. // // @param _sell the contract address of the token that will be sold // @param _buy the contract address of the token that will be purchased @@ -133,54 +132,38 @@ contract Recipe2 is KilnBase, TwapProduct { emit File(what, data); } - // TODO: try moving to regular stack vars, need to avoid stack too deep - struct Workspace { - uint256 halfIn; - bytes path; - uint256 yen; - uint256 zen; - uint256 quote; - uint256 bought; - } - function _swap(uint256 inAmount) internal override returns (uint256 swapped) { - Workspace memory ws = Workspace({ - halfIn: inAmount / 2, - path: path, - yen: yen, - zen: zen, - quote: 0, - bought: 0 - }); - ws.quote = quote(ws.path, ws.halfIn, uint32(scope)); + uint256 _halfIn = inAmount / 2; + bytes memory _path = path; + uint256 _quote = quote(_path, _halfIn, uint32(scope)); - GemLike(sell).approve(uniV3Router, ws.halfIn); + GemLike(sell).approve(uniV3Router, _halfIn); SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ - path: ws.path, + path: _path, recipient: address(this), deadline: block.timestamp, - amountIn: ws.halfIn, - amountOutMinimum: ws.quote * ws.yen / WAD + amountIn: _halfIn, + amountOutMinimum: _quote * yen / WAD }); - ws.bought = SwapRouterLike(uniV3Router).exactInput(params); - console.log("ws.halfIn %s, bought %s", ws.halfIn, ws.bought); + uint256 bought = SwapRouterLike(uniV3Router).exactInput(params); // In case the `sell` token deposit amount needs to be insisted on it means the full `bought` amount of buy tokens are deposited. // Therefore we want at least the reference price (halfIn / quote) factored by zen. - uint256 sellDepositMin = (ws.bought * ws.halfIn / ws.quote) * ws.zen / WAD; + uint256 _zen = zen; + uint256 sellDepositMin = (bought * _halfIn / _quote) * _zen / WAD; // In case the `buy` token deposit amount needs to be insisted on it means the full `halfIn` amount of sell tokens are deposited. // As `halflot` was also used in the quote calculation, it represents the exact reference price and only needs to be factored by zen - uint256 buyDepositMin = ws.quote * ws.zen / WAD; - console.log("sellDepositMin %s, buyDepositMin %s", sellDepositMin, buyDepositMin); - GemLike(sell).approve(uniV2Router, ws.halfIn); - GemLike(buy).approve(uniV2Router, ws.bought); + uint256 buyDepositMin = _quote * _zen / WAD; + + GemLike(sell).approve(uniV2Router, _halfIn); + GemLike(buy).approve(uniV2Router, bought); (, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ tokenA: sell, tokenB: buy, - amountADesired: ws.halfIn, - amountBDesired: ws.bought, + amountADesired: _halfIn, + amountBDesired: bought, amountAMin: sellDepositMin, amountBMin: buyDepositMin, to: receiver, @@ -189,8 +172,8 @@ contract Recipe2 is KilnBase, TwapProduct { swapped = liquidity; // If not all buy tokens were used, send the remainder to the receiver - if (ws.bought > amountB) { - GemLike(buy).transfer(receiver, ws.bought - amountB); + if (bought > amountB) { + GemLike(buy).transfer(receiver, bought - amountB); } } diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index f5b60d4..7dbdb02 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -104,7 +104,7 @@ contract KilnTest is Test { // When changing univ3 price we'll have to relate to half lot amount, as that's what fire() trades there refHalfLot = getRefOutAMount(halfLot); - console.log("refHalfLot: %s", refHalfLot); + // console.log("refHalfLot: %s", refHalfLot); // When changing univ2 price we'll use one WAD as reference fire only deposit theres (no price change) refOneWad = getRefOutAMount(WAD); @@ -121,7 +121,7 @@ contract KilnTest is Test { function changeUniv3Price(uint256 amountIn, uint256 minOutAmount, uint256 maxOutAMount) internal { uint256 current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("univ3 minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("univ3 minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); while (current < minOutAmount) { @@ -138,7 +138,7 @@ contract KilnTest is Test { SwapRouterLike(UNIV3ROUTER).exactInput(params); current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("univ3 driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("univ3 driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } while (current > maxOutAMount) { @@ -156,7 +156,7 @@ contract KilnTest is Test { SwapRouterLike(UNIV3ROUTER).exactInput(params); current = univ3Quoter.quoteExactInput(path, amountIn); - console.log("univ3 driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("univ3 driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } assert(current >= minOutAmount && current <= maxOutAMount); @@ -175,7 +175,7 @@ contract KilnTest is Test { function changeUniv2Price(uint256 amountIn, uint256 minOutAmount, uint256 maxOutAMount) internal { uint256 current = getUniv2AmountOut(amountIn); - console.log("univ2 minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("univ2 minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); while (current < minOutAmount) { @@ -195,7 +195,7 @@ contract KilnTest is Test { ); // deadline current = getUniv2AmountOut(amountIn); - console.log("univ2 driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("univ2 driving out amount up - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } while (current > maxOutAMount) { @@ -216,7 +216,7 @@ contract KilnTest is Test { ); // deadline current = getUniv2AmountOut(amountIn); - console.log("univ2 driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); + // console.log("univ2 driving out amount down - minOutAmount: %s, current: %s, maxOutAmount: %s", minOutAmount, current, maxOutAMount); } assert(current >= minOutAmount && current <= maxOutAMount); From 476d0d76e10cc39f2e44bb1e5a6c78dbbecb4c64 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Sun, 26 Feb 2023 16:07:06 +0200 Subject: [PATCH 11/19] Initial use of quoter --- src/QuoterTwap.sol | 84 ++++++++++++++++++++++++++++++++++++++++++++++ src/Recipe2.sol | 41 ++++++++++++++++------ src/Recipe2.t.sol | 14 ++++++-- 3 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 src/QuoterTwap.sol diff --git a/src/QuoterTwap.sol b/src/QuoterTwap.sol new file mode 100644 index 0000000..bada4fc --- /dev/null +++ b/src/QuoterTwap.sol @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: © 2023 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.14; + +import {TwapProduct} from "./uniV3/TwapProduct.sol"; + +// TODO: implement quoter interface? +contract QuoterTwap is TwapProduct { + mapping (address => uint256) public wards; + + uint256 public scope; // [Seconds] Time period for TWAP calculations + bytes public path; // ABI-encoded UniV3 compatible path + + event Rely(address indexed usr); + event Deny(address indexed usr); + event File(bytes32 indexed what, uint256 data); + event File(bytes32 indexed what, bytes data); + + // TODO: consider merging with TwapProduct + // TODO: documentation ? + constructor(address _uniV3Factory) TwapProduct(_uniV3Factory) + { + scope = 1 hours; + + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + modifier auth { + require(wards[msg.sender] == 1, "QuoterTwap/not-authorized"); + _; + } + + /** + @dev Auth'ed function to authorize an address for privileged functions + @param usr Address to be authorized + */ + function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } + + /** + @dev Auth'ed function to un-authorize an address for privileged functions + @param usr Address to be un-authorized + */ + function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } + + // TODO: documentation + function file(bytes32 what, uint256 data) external auth { + if (what == "scope") { + require(data > 0, "QuoterTwap/zero-scope"); + require(data <= uint32(type(int32).max), "Recipe2/scope-overflow"); + scope = data; + } else revert("QuoterTwap/file-unrecognized-param"); + emit File(what, data); + } + + /** + @dev Auth'ed function to update path value + @param what Tag of value to update + @param data Value to update + */ + function file(bytes32 what, bytes calldata data) external auth { + if (what == "path") path = data; + else revert("QuoterTwap/file-unrecognized-param"); + emit File(what, data); + } + + function quote(address, address, uint256 amount) external view returns (uint256 outAMount) { + outAMount = quote(path, amount, uint32(scope)); + } +} diff --git a/src/Recipe2.sol b/src/Recipe2.sol index 82cbe23..68df68b 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -17,7 +17,6 @@ pragma solidity ^0.8.14; import {KilnBase, GemLike} from "./KilnBase.sol"; -import {TwapProduct} from "./uniV3/TwapProduct.sol"; // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol interface SwapRouterLike { @@ -49,20 +48,27 @@ interface UniswapV2Router02Like { ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity); } -contract Recipe2 is KilnBase, TwapProduct { - uint256 public scope; // [Seconds] Time period for TWAP calculations +interface Quoter { + function quote(address sell, address buy, uint256 amount) external returns (uint256 outAMount); +} + +// TODO: update documentation to be general +contract Recipe2 is KilnBase { uint256 public yen; // [WAD] Relative multiplier of the Univ3 TWAP price to insist on in the UniV3 trade // For example: 0.98 * WAD allows 2% worse price than the V3 TWAP uint256 public zen; // [WAD] Allowed Univ2 deposit price deviations from the Univ3 TWAP price. Must be <= WAD // For example: 0.97 * WAD allows 3% price deviation to either side. bytes public path; // ABI-encoded UniV3 compatible path + address[] public quoters; + address public immutable uniV2Router; address public immutable uniV3Router; address public immutable receiver; event File(bytes32 indexed what, bytes data); + // TODO: update documentation // @notice initialize a Uniswap V3 routing path contract // @dev In order to complete fire() has to trade on UniV3 and deposit to UniV2. With the initial constructor value of // `yen` == WAD, fire will trade on Univ3 only when the amount of tokens received is equal or better than the Univ3 @@ -83,19 +89,21 @@ contract Recipe2 is KilnBase, TwapProduct { address _receiver ) KilnBase(_sell, _buy) - TwapProduct(SwapRouterLike(_uniV3Router).factory()) { uniV2Router = _uniV2Router; uniV3Router = _uniV3Router; receiver = _receiver; - scope = 1 hours; yen = WAD; zen = WAD; } uint256 constant WAD = 10 ** 18; + function _max(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = x >= y ? x : y; + } + /** @dev Auth'ed function to update path value @param what Tag of value to update @@ -107,6 +115,7 @@ contract Recipe2 is KilnBase, TwapProduct { emit File(what, data); } + // TODO: update documentation /** @dev Auth'ed function to update yen, scope, or base contract derived values Warning - setting `yen` or `zen` as a low value highly increases the susceptibility to oracle manipulation attacks @@ -121,10 +130,6 @@ contract Recipe2 is KilnBase, TwapProduct { } else if (what == "zen") { require(data > 0, "Recipe2/zero-zen"); zen = data; - } else if (what == "scope") { - require(data > 0, "Recipe2/zero-scope"); - require(data <= uint32(type(int32).max), "Recipe2/scope-overflow"); - scope = data; } else { super.file(what, data); return; @@ -132,11 +137,27 @@ contract Recipe2 is KilnBase, TwapProduct { emit File(what, data); } + // TODO: documentation + function addQuoter(address quoter) external auth { + quoters.push(quoter); + } + + function removeQuoter(uint256 index) external auth { + quoters[index] = quoters[quoters.length - 1]; + quoters.pop(); + } + + function quote(address sell, address buy, uint256 amount) public returns (uint256 outAMount) { + for(uint256 i; i < quoters.length; i++) { + outAMount = _max(outAMount, Quoter(quoters[i]).quote(sell, buy, amount)); + } + } + function _swap(uint256 inAmount) internal override returns (uint256 swapped) { uint256 _halfIn = inAmount / 2; bytes memory _path = path; - uint256 _quote = quote(_path, _halfIn, uint32(scope)); + uint256 _quote = quote(sell, buy, _halfIn); // TODO: check if can avoid re-read of sell and buy GemLike(sell).approve(uniV3Router, _halfIn); SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index 7dbdb02..46b231e 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -17,7 +17,8 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; -import "./Recipe2.sol"; +import "src/Recipe2.sol"; +import "src/QuoterTwap.sol"; import "src/uniV2/UniswapV2Library.sol"; import "src/uniV2/IUniswapV2Pair.sol"; @@ -62,6 +63,7 @@ contract KilnTest is Test { using UniswapV2Library for *; Recipe2 kiln; + QuoterTwap qtwap; Univ3Quoter univ3Quoter; User user; @@ -102,6 +104,10 @@ contract KilnTest is Test { kiln.file("path", path); halfLot = kiln.lot() / 2; + qtwap = new QuoterTwap(UNIV3FACTORY); + qtwap.file("path", path); + kiln.addQuoter(address(qtwap)); + // When changing univ3 price we'll have to relate to half lot amount, as that's what fire() trades there refHalfLot = getRefOutAMount(halfLot); // console.log("refHalfLot: %s", refHalfLot); @@ -116,7 +122,7 @@ contract KilnTest is Test { } function getRefOutAMount(uint256 amountIn) internal view returns (uint256) { - return kiln.quote(kiln.path(), amountIn, uint32(kiln.scope())); + return qtwap.quote(address(0), address(0), amountIn); } function changeUniv3Price(uint256 amountIn, uint256 minOutAmount, uint256 maxOutAMount) internal { @@ -254,6 +260,8 @@ contract KilnTest is Test { kiln.file("zen", 0); } + // TODO: move to new quoter + /* function testFileScope() public { vm.expectEmit(true, true, false, false); emit File(bytes32("scope"), 314); @@ -266,10 +274,12 @@ contract KilnTest is Test { kiln.file("scope", 0); } + function testFileScopeTooLarge() public { vm.expectRevert("Recipe2/scope-overflow"); kiln.file("scope", uint32(type(int32).max) + 1); } + */ function testFileBytesUnrecognized() public { vm.expectRevert("Recipe2/file-unrecognized-param"); From 0613541023d49aecb847a84e2155537d278a8c0e Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Sun, 26 Feb 2023 18:53:13 +0200 Subject: [PATCH 12/19] Tests pass, lots of cleanups needed --- src/KilnUniV3.sol | 45 +++++++--- src/KilnUniV3.t.sol | 18 ++-- src/QuoterTwap.sol | 84 ------------------- src/Recipe2.t.sol | 6 +- .../QuoterTwapProduct.sol} | 69 +++++++++++---- .../QuoterTwapProduct.t.sol} | 70 ++++++++-------- 6 files changed, 140 insertions(+), 152 deletions(-) delete mode 100644 src/QuoterTwap.sol rename src/{uniV3/TwapProduct.sol => quoters/QuoterTwapProduct.sol} (62%) rename src/{uniV3/TwapProduct.t.sol => quoters/QuoterTwapProduct.t.sol} (69%) diff --git a/src/KilnUniV3.sol b/src/KilnUniV3.sol index 5158be8..a05402f 100644 --- a/src/KilnUniV3.sol +++ b/src/KilnUniV3.sol @@ -17,7 +17,7 @@ pragma solidity ^0.8.14; import {KilnBase, GemLike} from "./KilnBase.sol"; -import {TwapProduct} from "./uniV3/TwapProduct.sol"; +import {QuoterTwapProduct} from "./quoters/QuoterTwapProduct.sol"; // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol interface SwapRouterLike { @@ -35,16 +35,22 @@ struct ExactInputParams { uint256 amountOutMinimum; } -contract KilnUniV3 is KilnBase, TwapProduct { - uint256 public scope; // [Seconds] Time period for TWAP calculations +interface Quoter { + function quote(address sell, address buy, uint256 amount) external returns (uint256 outAMount); +} + +contract KilnUniV3 is KilnBase { uint256 public yen; // [WAD] Relative multiplier of the TWAP's price to insist on bytes public path; // ABI-encoded UniV3 compatible path + address[] public quoters; + address public immutable uniV3Router; address public immutable receiver; event File(bytes32 indexed what, bytes data); + // TODO: update documentation // @notice initialize a Uniswap V3 routing path contract // @dev TWAP-relative trading is enabled by default. With the initial values, fire will // perform the trade only when the amount of tokens received is equal or better than @@ -60,17 +66,19 @@ contract KilnUniV3 is KilnBase, TwapProduct { address _receiver ) KilnBase(_sell, _buy) - TwapProduct(SwapRouterLike(_uniV3Router).factory()) { uniV3Router = _uniV3Router; receiver = _receiver; - scope = 1 hours; yen = WAD; } uint256 constant WAD = 10 ** 18; + function _max(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = x >= y ? x : y; + } + /** @dev Auth'ed function to update path value @param what Tag of value to update @@ -82,6 +90,7 @@ contract KilnUniV3 is KilnBase, TwapProduct { emit File(what, data); } + // TODO: update documentation /** @dev Auth'ed function to update yen, scope, or base contract derived values Warning - setting `yen` as 0 or another low value highly increases the susceptibility to oracle manipulation attacks @@ -90,25 +99,37 @@ contract KilnUniV3 is KilnBase, TwapProduct { @param data Value to update */ function file(bytes32 what, uint256 data) public override auth { - if (what == "yen") yen = data; - else if (what == "scope") { - require(data > 0, "KilnUniV3/zero-scope"); - require(data <= uint32(type(int32).max), "KilnUniV3/scope-overflow"); - scope = data; - } else { + if (what == "yen") yen = data; + else { super.file(what, data); return; } emit File(what, data); } + // TODO: documentation + function addQuoter(address quoter) external auth { + quoters.push(quoter); + } + + function removeQuoter(uint256 index) external auth { + quoters[index] = quoters[quoters.length - 1]; + quoters.pop(); + } + + function quote(address sell, address buy, uint256 amount) public returns (uint256 outAMount) { + for(uint256 i; i < quoters.length; i++) { + outAMount = _max(outAMount, Quoter(quoters[i]).quote(sell, buy, amount)); + } + } + function _swap(uint256 amount) internal override returns (uint256 swapped) { GemLike(sell).approve(uniV3Router, amount); bytes memory _path = path; uint256 _yen = yen; - uint256 amountMin = (_yen != 0) ? quote(_path, amount, uint32(scope)) * _yen / WAD : 0; + uint256 amountMin = (_yen != 0) ? quote(sell, buy, amount) * _yen / WAD : 0; ExactInputParams memory params = ExactInputParams({ path: _path, diff --git a/src/KilnUniV3.t.sol b/src/KilnUniV3.t.sol index 1128ec3..851edf1 100644 --- a/src/KilnUniV3.t.sol +++ b/src/KilnUniV3.t.sol @@ -24,7 +24,7 @@ interface TestGem { } // https://github.com/Uniswap/v3-periphery/blob/v1.0.0/contracts/lens/Quoter.sol#L106-L122 -interface Quoter { +interface Univ3Quoter { function quoteExactInput( bytes calldata path, uint256 amountIn @@ -35,7 +35,8 @@ contract User {} contract KilnTest is Test { KilnUniV3 kiln; - Quoter quoter; + QuoterTwapProduct qtwap; + Univ3Quoter quoter; User user; bytes path; @@ -59,12 +60,16 @@ contract KilnTest is Test { path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); kiln = new KilnUniV3(DAI, MKR, ROUTER, address(user)); - quoter = Quoter(QUOTER); + quoter = Univ3Quoter(QUOTER); kiln.file("lot", 50_000 * WAD); kiln.file("hop", 6 hours); kiln.file("path", path); + qtwap = new QuoterTwapProduct(FACTORY); + qtwap.file("path", path); + kiln.addQuoter(address(qtwap)); + kiln.file("yen", 50 * WAD / 100); // Insist on very little on default } @@ -113,6 +118,8 @@ contract KilnTest is Test { assertEq(kiln.yen(), 42); } + // TODO: move to quoter tests + /* function testFileScope() public { vm.expectEmit(true, true, false, false); emit File(bytes32("scope"), 314); @@ -129,6 +136,7 @@ contract KilnTest is Test { vm.expectRevert("KilnUniV3/scope-overflow"); kiln.file("scope", uint32(type(int32).max) + 1); } + */ function testFileBytesUnrecognized() public { vm.expectRevert("KilnUniV3/file-unrecognized-param"); @@ -268,8 +276,8 @@ contract KilnTest is Test { mintDai(address(kiln), 1_000_000 * WAD); kiln.file("hop", 0 hours); // for convenience allow firing right away - kiln.file("scope", 1 hours); kiln.file("yen", 120 * WAD / 100); // only swap if price rose by 20% vs twap + qtwap.file("scope", 1 hours); uint256 mkrBefore = GemLike(MKR).balanceOf(address(this)); @@ -304,8 +312,8 @@ contract KilnTest is Test { mintDai(address(kiln), 1_000_000 * WAD); kiln.file("hop", 0 hours); // for convenience allow firing right away - kiln.file("scope", 1 hours); kiln.file("yen", 80 * WAD / 100); // allow swap even if price fell by 20% vs twap + qtwap.file("scope", 1 hours); // make sure twap measures regular MKR out amount at the beginning of the hour (by making small swap) vm.roll(block.number + 1); diff --git a/src/QuoterTwap.sol b/src/QuoterTwap.sol deleted file mode 100644 index bada4fc..0000000 --- a/src/QuoterTwap.sol +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-FileCopyrightText: © 2023 Dai Foundation -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -pragma solidity ^0.8.14; - -import {TwapProduct} from "./uniV3/TwapProduct.sol"; - -// TODO: implement quoter interface? -contract QuoterTwap is TwapProduct { - mapping (address => uint256) public wards; - - uint256 public scope; // [Seconds] Time period for TWAP calculations - bytes public path; // ABI-encoded UniV3 compatible path - - event Rely(address indexed usr); - event Deny(address indexed usr); - event File(bytes32 indexed what, uint256 data); - event File(bytes32 indexed what, bytes data); - - // TODO: consider merging with TwapProduct - // TODO: documentation ? - constructor(address _uniV3Factory) TwapProduct(_uniV3Factory) - { - scope = 1 hours; - - wards[msg.sender] = 1; - emit Rely(msg.sender); - } - - modifier auth { - require(wards[msg.sender] == 1, "QuoterTwap/not-authorized"); - _; - } - - /** - @dev Auth'ed function to authorize an address for privileged functions - @param usr Address to be authorized - */ - function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } - - /** - @dev Auth'ed function to un-authorize an address for privileged functions - @param usr Address to be un-authorized - */ - function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } - - // TODO: documentation - function file(bytes32 what, uint256 data) external auth { - if (what == "scope") { - require(data > 0, "QuoterTwap/zero-scope"); - require(data <= uint32(type(int32).max), "Recipe2/scope-overflow"); - scope = data; - } else revert("QuoterTwap/file-unrecognized-param"); - emit File(what, data); - } - - /** - @dev Auth'ed function to update path value - @param what Tag of value to update - @param data Value to update - */ - function file(bytes32 what, bytes calldata data) external auth { - if (what == "path") path = data; - else revert("QuoterTwap/file-unrecognized-param"); - emit File(what, data); - } - - function quote(address, address, uint256 amount) external view returns (uint256 outAMount) { - outAMount = quote(path, amount, uint32(scope)); - } -} diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index 46b231e..d921888 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -18,7 +18,7 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; import "src/Recipe2.sol"; -import "src/QuoterTwap.sol"; +import "src/quoters/QuoterTwapProduct.sol"; import "src/uniV2/UniswapV2Library.sol"; import "src/uniV2/IUniswapV2Pair.sol"; @@ -63,7 +63,7 @@ contract KilnTest is Test { using UniswapV2Library for *; Recipe2 kiln; - QuoterTwap qtwap; + QuoterTwapProduct qtwap; Univ3Quoter univ3Quoter; User user; @@ -104,7 +104,7 @@ contract KilnTest is Test { kiln.file("path", path); halfLot = kiln.lot() / 2; - qtwap = new QuoterTwap(UNIV3FACTORY); + qtwap = new QuoterTwapProduct(UNIV3FACTORY); qtwap.file("path", path); kiln.addQuoter(address(qtwap)); diff --git a/src/uniV3/TwapProduct.sol b/src/quoters/QuoterTwapProduct.sol similarity index 62% rename from src/uniV3/TwapProduct.sol rename to src/quoters/QuoterTwapProduct.sol index 1948a9c..51863be 100644 --- a/src/uniV3/TwapProduct.sol +++ b/src/quoters/QuoterTwapProduct.sol @@ -16,10 +16,10 @@ pragma solidity ^0.8.14; -import {FullMath} from "./FullMath.sol"; -import {TickMath} from "./TickMath.sol"; -import {PoolAddress} from "./PoolAddress.sol"; -import {Path} from "./Path.sol"; +import {FullMath} from "src/uniV3/FullMath.sol"; +import {TickMath} from "src/uniV3/TickMath.sol"; +import {PoolAddress} from "src/uniV3/PoolAddress.sol"; +import {Path} from "src/uniV3/Path.sol"; // https://github.com/Uniswap/v3-core/blob/412d9b236a1e75a98568d49b1aeb21e3a1430544/contracts/UniswapV3Pool.sol interface UniswapV3PoolLike { @@ -27,29 +27,68 @@ interface UniswapV3PoolLike { function observe(uint32[] calldata) external view returns (int56[] memory, uint160[] memory); } -contract TwapProduct { +contract QuoterTwapProduct { using Path for bytes; + mapping (address => uint256) public wards; + uint256 public scope; // [Seconds] Time period for TWAP calculations + bytes public path; // ABI-encoded UniV3 compatible path + address public immutable uniFactory; + event Rely(address indexed usr); + event Deny(address indexed usr); + event File(bytes32 indexed what, uint256 data); + event File(bytes32 indexed what, bytes data); + constructor(address _uniFactory) { uniFactory = _uniFactory; + + scope = 1 hours; + + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + modifier auth { + require(wards[msg.sender] == 1, "QuoterTwapProduct/not-authorized"); + _; + } + + function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } + function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } + + // TODO: documentation + function file(bytes32 what, uint256 data) external auth { + if (what == "scope") { + require(data > 0, "QuoterTwapProduct/zero-scope"); + require(data <= uint32(type(int32).max), "Recipe2/scope-overflow"); + scope = data; + } else revert("QuoterTwapProduct/file-unrecognized-param"); + emit File(what, data); + } + + function file(bytes32 what, bytes calldata data) external auth { + if (what == "path") path = data; + else revert("QuoterTwapProduct/file-unrecognized-param"); + emit File(what, data); } // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/lens/Quoter.sol#L106 - function quote(bytes memory path, uint256 amountIn, uint32 scope) public view returns (uint256 amountOut) { + function quote(address, address, uint256 amountIn) external view returns (uint256 amountOut) { + bytes memory _path = path; while (true) { - bool hasMultiplePools = path.hasMultiplePools(); + bool hasMultiplePools = _path.hasMultiplePools(); - (address tokenIn, address tokenOut, uint24 fee) = path.decodeFirstPool(); - int24 arithmeticMeanTick = _consult(_getPool(tokenIn, tokenOut, fee), scope); + (address tokenIn, address tokenOut, uint24 fee) = _path.decodeFirstPool(); + int24 arithmeticMeanTick = _consult(_getPool(tokenIn, tokenOut, fee), uint32(scope)); - require(amountIn <= type(uint128).max, "TwapProduct/amountIn-overflow"); + require(amountIn <= type(uint128).max, "QuoterTwapProduct/amountIn-overflow"); amountIn = _getQuoteAtTick(arithmeticMeanTick, uint128(amountIn), tokenIn, tokenOut); // Decide whether to continue or terminate if (hasMultiplePools) { - path = path.skipToken(); + _path = _path.skipToken(); } else { return amountIn; } @@ -62,18 +101,18 @@ contract TwapProduct { } // https://github.com/Uniswap/v3-periphery/blob/51f8871aaef2263c8e8bbf4f3410880b6162cdea/contracts/libraries/OracleLibrary.sol#L16 - function _consult(UniswapV3PoolLike pool, uint32 scope) internal view returns (int24 arithmeticMeanTick) { + function _consult(UniswapV3PoolLike pool, uint32 _scope) internal view returns (int24 arithmeticMeanTick) { uint32[] memory secondsAgos = new uint32[](2); - secondsAgos[0] = scope; + secondsAgos[0] = _scope; secondsAgos[1] = 0; (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos); int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0]; - arithmeticMeanTick = int24(tickCumulativesDelta / int56(int32(scope))); + arithmeticMeanTick = int24(tickCumulativesDelta / int56(int32(_scope))); // Always round to negative infinity - if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int56(int32(scope)) != 0)) arithmeticMeanTick--; + if (tickCumulativesDelta < 0 && (tickCumulativesDelta % int56(int32(_scope)) != 0)) arithmeticMeanTick--; } // https://github.com/Uniswap/v3-periphery/blob/51f8871aaef2263c8e8bbf4f3410880b6162cdea/contracts/libraries/OracleLibrary.sol#L49 diff --git a/src/uniV3/TwapProduct.t.sol b/src/quoters/QuoterTwapProduct.t.sol similarity index 69% rename from src/uniV3/TwapProduct.t.sol rename to src/quoters/QuoterTwapProduct.t.sol index e574452..4fac229 100644 --- a/src/uniV3/TwapProduct.t.sol +++ b/src/quoters/QuoterTwapProduct.t.sol @@ -17,19 +17,19 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; -import "./TwapProduct.sol"; +import "src/quoters/QuoterTwapProduct.sol"; -// https://github.com/Uniswap/v3-periphery/blob/v1.0.0/contracts/lens/Quoter.sol#L106-L122 -interface Quoter { +// https://github.com/Uniswap/v3-periphery/blob/v1.0.0/contracts/lens/Univ3Quoter.sol#L106-L122 +interface Univ3Quoter { function quoteExactInput( bytes calldata path, uint256 amountIn ) external returns (uint256 amountOut); } -contract TwapProductTest is Test { - TwapProduct tpQuoter; - Quoter quoter; +contract QuoterTwapProductTest is Test { + QuoterTwapProduct tpQuoter; + Univ3Quoter quoter; uint256 amtIn; uint32 scope; @@ -62,114 +62,118 @@ contract TwapProductTest is Test { } function setUp() public { - quoter = Quoter(QUOTER); - tpQuoter = new TwapProduct(UNIFACTORY); + quoter = Univ3Quoter(QUOTER); + tpQuoter = new QuoterTwapProduct(UNIFACTORY); // default testing values path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); amtIn = 30_000 * WAD; scope = 0.5 hours; + + tpQuoter.file("path", path); + tpQuoter.file("scope", scope); } function testSingleHopPath() public { - path = abi.encodePacked(USDC, uint24(500), WETH); + bytes memory _path = abi.encodePacked(USDC, uint24(500), WETH); + tpQuoter.file("path", _path); amtIn = 30_000 * 1e6; - uint256 quoterAmt = quoter.quoteExactInput(path, amtIn); - uint256 tpQuoterAmt = tpQuoter.quote(path, amtIn, scope); + uint256 quoterAmt = quoter.quoteExactInput(_path, amtIn); + uint256 tpQuoterAmt = tpQuoter.quote(address(0), address(0), amtIn); assertEqApproxBPS(tpQuoterAmt, quoterAmt, 500); } function testMultiHopPath() public { uint256 quoterAmt = quoter.quoteExactInput(path, amtIn); - uint256 tpQuoterAmt = tpQuoter.quote(path, amtIn, scope); + uint256 tpQuoterAmt = tpQuoter.quote(address(0), address(0), amtIn); assertEqApproxBPS(tpQuoterAmt, quoterAmt, 500); } function testInvalidPathSingleToken() public { - path = abi.encodePacked(USDC); + tpQuoter.file("path", abi.encodePacked(USDC)); vm.expectRevert("toUint24_outOfBounds"); - tpQuoter.quote(path, amtIn, scope); + tpQuoter.quote(address(0), address(0), amtIn); } function testInvalidPathSameToken() public { - path = abi.encodePacked(USDC, uint24(500), USDC); + tpQuoter.file("path", abi.encodePacked(USDC, uint24(500), USDC)); vm.expectRevert(); - tpQuoter.quote(path, amtIn, scope); + tpQuoter.quote(address(0), address(0), amtIn); } function testInvalidPathTwoFees() public { - path = abi.encodePacked(USDC, uint24(500), uint24(500), USDC); + tpQuoter.file("path", abi.encodePacked(USDC, uint24(500), uint24(500), USDC)); vm.expectRevert(); - tpQuoter.quote(path, amtIn, scope); + tpQuoter.quote(address(0), address(0), amtIn); } function testInvalidPathWrongFees() public { - path = abi.encodePacked(USDC, uint24(501), USDC); + tpQuoter.file("path", abi.encodePacked(USDC, uint24(501), USDC)); vm.expectRevert(); - tpQuoter.quote(path, amtIn, scope); + tpQuoter.quote(address(0), address(0), amtIn); } function testZeroAmt() public { amtIn = 0; - uint256 tpQuoterAmt = tpQuoter.quote(path, amtIn, scope); + uint256 tpQuoterAmt = tpQuoter.quote(address(0), address(0), amtIn); assertEq(tpQuoterAmt, 0); } function testTooLargeAmt() public { amtIn = uint256(type(uint128).max) + 1; - vm.expectRevert("TwapProduct/amountIn-overflow"); - tpQuoter.quote(path, amtIn, scope); + vm.expectRevert("QuoterTwapProduct/amountIn-overflow"); + tpQuoter.quote(address(0), address(0), amtIn); } // TWAP returns the counterfactual accumulator values at exactly the timestamp between two observations. // This means that a small scope should lean very close to the current price. function testSmallScope() public { - scope = 1 seconds; + tpQuoter.file("scope", 1 seconds); uint256 quoterAmt = quoter.quoteExactInput(path, amtIn); - uint256 tpQuoterAmt = tpQuoter.quote(path, amtIn, scope); + uint256 tpQuoterAmt = tpQuoter.quote(address(0), address(0), amtIn); assertEqApproxBPS(tpQuoterAmt, quoterAmt, 500); // Note that there is still price impact for amtIn } function testSmallScopeSmallAmt() public { amtIn = 1 * WAD / 100; - scope = 1 seconds; + tpQuoter.file("scope", 1 seconds); uint256 quoterAmt = quoter.quoteExactInput(path, amtIn); - uint256 tpQuoterAmt = tpQuoter.quote(path, amtIn, scope); + uint256 tpQuoterAmt = tpQuoter.quote(address(0), address(0), amtIn); assertEqApproxBPS(tpQuoterAmt, quoterAmt, 100); // Price impact for amtIn should be minimized } // using testFail as division by zero is not supported for vm.expectRevert function testFailZeroScope() public { - scope = 0 seconds; - tpQuoter.quote(path, amtIn, scope); + tpQuoter.file("scope", 0 seconds); + tpQuoter.quote(address(0), address(0), amtIn); } function testTooLargeScope() public { - scope = 100000 seconds; + tpQuoter.file("scope", 100000 seconds); // https://github.com/Uniswap/v3-core/blob/fc2107bd5709cdee6742d5164c1eb998566bcb75/contracts/libraries/Oracle.sol#L226 vm.expectRevert(bytes("OLD")); - tpQuoter.quote(path, amtIn, scope); + tpQuoter.quote(address(0), address(0), amtIn); } // Can be used for accumulating statistics through a wrapping script function testStat() public { amtIn = 30_000 * WAD; - scope = 30 minutes; + tpQuoter.file("scope", 30 minutes); uint256 quoterAmt = quoter.quoteExactInput(path, amtIn); - uint256 tpQuoterAmt = tpQuoter.quote(path, amtIn, scope); + uint256 tpQuoterAmt = tpQuoter.quote(address(0), address(0), amtIn); uint256 ratio = quoterAmt * WAD / tpQuoterAmt; console.log('{"tag": "Debug", "block": %s, "timestamp": %s, "ratio": %s}', block.number, block.timestamp, ratio); From 1efafc114173d813e939189fb430db0a296ff5b2 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Sun, 26 Feb 2023 21:11:14 +0200 Subject: [PATCH 13/19] Cleanup, docs, missing tests - WIP --- src/KilnUniV3.sol | 41 +++++++++++----------- src/KilnUniV3.t.sol | 23 ++----------- src/Recipe2.sol | 53 +++++++++++++---------------- src/quoters/IQuoter.sol | 22 ++++++++++++ src/quoters/QuoterTwapProduct.sol | 17 +++++++-- src/quoters/QuoterTwapProduct.t.sol | 34 ++++++++++++++++++ 6 files changed, 114 insertions(+), 76 deletions(-) create mode 100644 src/quoters/IQuoter.sol diff --git a/src/KilnUniV3.sol b/src/KilnUniV3.sol index a05402f..5a2a008 100644 --- a/src/KilnUniV3.sol +++ b/src/KilnUniV3.sol @@ -16,8 +16,8 @@ pragma solidity ^0.8.14; -import {KilnBase, GemLike} from "./KilnBase.sol"; -import {QuoterTwapProduct} from "./quoters/QuoterTwapProduct.sol"; +import {KilnBase, GemLike} from "src/KilnBase.sol"; +import {IQuoter} from "src/quoters/IQuoter.sol"; // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol interface SwapRouterLike { @@ -35,14 +35,10 @@ struct ExactInputParams { uint256 amountOutMinimum; } -interface Quoter { - function quote(address sell, address buy, uint256 amount) external returns (uint256 outAMount); -} - contract KilnUniV3 is KilnBase { - uint256 public yen; // [WAD] Relative multiplier of the TWAP's price to insist on - bytes public path; // ABI-encoded UniV3 compatible path - + uint256 public yen; // [WAD] Relative multiplier of the reference price to insist on in the UniV3 trade. + // For example: 0.98 * WAD allows 2% worse price than the reference. + bytes public path; // ABI-encoded UniV3 compatible path address[] public quoters; address public immutable uniV3Router; @@ -50,11 +46,6 @@ contract KilnUniV3 is KilnBase { event File(bytes32 indexed what, bytes data); - // TODO: update documentation - // @notice initialize a Uniswap V3 routing path contract - // @dev TWAP-relative trading is enabled by default. With the initial values, fire will - // perform the trade only when the amount of tokens received is equal or better than - // the 1 hour average price. // @param _sell the contract address of the token that will be sold // @param _buy the contract address of the token that will be purchased // @param _uniV3Router the address of the current Uniswap V3 swap router @@ -90,11 +81,9 @@ contract KilnUniV3 is KilnBase { emit File(what, data); } - // TODO: update documentation /** - @dev Auth'ed function to update yen, scope, or base contract derived values + @dev Auth'ed function to update yen or base contract derived values Warning - setting `yen` as 0 or another low value highly increases the susceptibility to oracle manipulation attacks - Warning - a low `scope` increases the susceptibility to oracle manipulation attacks @param what Tag of value to update @param data Value to update */ @@ -107,19 +96,27 @@ contract KilnUniV3 is KilnBase { emit File(what, data); } - // TODO: documentation + /** + @dev Auth'ed function to add a quoter contract + @param quoter Address of the quoter contract + */ function addQuoter(address quoter) external auth { quoters.push(quoter); } + /** + @dev Auth'ed function to remove a quoter contract + @param index Index of the quoter contract to be removed + */ function removeQuoter(uint256 index) external auth { quoters[index] = quoters[quoters.length - 1]; quoters.pop(); } - function quote(address sell, address buy, uint256 amount) public returns (uint256 outAMount) { - for(uint256 i; i < quoters.length; i++) { - outAMount = _max(outAMount, Quoter(quoters[i]).quote(sell, buy, amount)); + // Note: although sell and buy tokens are passed there is no guarantee that the quoters will use/validate them + function _quote(uint256 amount) internal view returns (uint256 outAmount) { + for (uint256 i; i < quoters.length; i++) { + outAmount = _max(outAmount, IQuoter(quoters[i]).quote(sell, buy, amount)); } } @@ -129,7 +126,7 @@ contract KilnUniV3 is KilnBase { bytes memory _path = path; uint256 _yen = yen; - uint256 amountMin = (_yen != 0) ? quote(sell, buy, amount) * _yen / WAD : 0; + uint256 amountMin = (_yen != 0) ? _quote(amount) * _yen / WAD : 0; ExactInputParams memory params = ExactInputParams({ path: _path, diff --git a/src/KilnUniV3.t.sol b/src/KilnUniV3.t.sol index 851edf1..325fd77 100644 --- a/src/KilnUniV3.t.sol +++ b/src/KilnUniV3.t.sol @@ -17,7 +17,8 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; -import "./KilnUniV3.sol"; +import "src/KilnUniV3.sol"; +import "src/quoters/QuoterTwapProduct.sol"; interface TestGem { function totalSupply() external view returns (uint256); @@ -118,26 +119,6 @@ contract KilnTest is Test { assertEq(kiln.yen(), 42); } - // TODO: move to quoter tests - /* - function testFileScope() public { - vm.expectEmit(true, true, false, false); - emit File(bytes32("scope"), 314); - kiln.file("scope", 314); - assertEq(kiln.scope(), 314); - } - - function testFileZeroScope() public { - vm.expectRevert("KilnUniV3/zero-scope"); - kiln.file("scope", 0); - } - - function testFileScopeTooLarge() public { - vm.expectRevert("KilnUniV3/scope-overflow"); - kiln.file("scope", uint32(type(int32).max) + 1); - } - */ - function testFileBytesUnrecognized() public { vm.expectRevert("KilnUniV3/file-unrecognized-param"); kiln.file("nonsense", bytes("")); diff --git a/src/Recipe2.sol b/src/Recipe2.sol index 68df68b..8847174 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -16,7 +16,8 @@ pragma solidity ^0.8.14; -import {KilnBase, GemLike} from "./KilnBase.sol"; +import {KilnBase, GemLike} from "src/KilnBase.sol"; +import {IQuoter} from "src/quoters/IQuoter.sol"; // https://github.com/Uniswap/v3-periphery/blob/b06959dd01f5999aa93e1dc530fe573c7bb295f6/contracts/SwapRouter.sol interface SwapRouterLike { @@ -48,18 +49,12 @@ interface UniswapV2Router02Like { ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity); } -interface Quoter { - function quote(address sell, address buy, uint256 amount) external returns (uint256 outAMount); -} - -// TODO: update documentation to be general contract Recipe2 is KilnBase { - uint256 public yen; // [WAD] Relative multiplier of the Univ3 TWAP price to insist on in the UniV3 trade - // For example: 0.98 * WAD allows 2% worse price than the V3 TWAP - uint256 public zen; // [WAD] Allowed Univ2 deposit price deviations from the Univ3 TWAP price. Must be <= WAD + uint256 public yen; // [WAD] Relative multiplier of the reference price to insist on in the UniV3 trade. + // For example: 0.98 * WAD allows 2% worse price than the reference. + uint256 public zen; // [WAD] Allowed Univ2 deposit price deviations from the reference price. Must be <= WAD // For example: 0.97 * WAD allows 3% price deviation to either side. - bytes public path; // ABI-encoded UniV3 compatible path - + bytes public path; // ABI-encoded UniV3 compatible path address[] public quoters; address public immutable uniV2Router; @@ -68,14 +63,6 @@ contract Recipe2 is KilnBase { event File(bytes32 indexed what, bytes data); - // TODO: update documentation - // @notice initialize a Uniswap V3 routing path contract - // @dev In order to complete fire() has to trade on UniV3 and deposit to UniV2. With the initial constructor value of - // `yen` == WAD, fire will trade on Univ3 only when the amount of tokens received is equal or better than the Univ3 - // 1 hour average price (`reference price`). - // For the Univ2 deposit to work `zen` has to be reduced from the default value of WAD by the allowed - // divergence from the reference price. - // // @param _sell the contract address of the token that will be sold // @param _buy the contract address of the token that will be purchased // @param _uniV2Router the address of the current Uniswap V2 swap router @@ -115,11 +102,9 @@ contract Recipe2 is KilnBase { emit File(what, data); } - // TODO: update documentation /** - @dev Auth'ed function to update yen, scope, or base contract derived values + @dev Auth'ed function to update yen, zen, or base contract derived values Warning - setting `yen` or `zen` as a low value highly increases the susceptibility to oracle manipulation attacks - Warning - a low `scope` increases the susceptibility to oracle manipulation attacks @param what Tag of value to update @param data Value to update */ @@ -137,19 +122,27 @@ contract Recipe2 is KilnBase { emit File(what, data); } - // TODO: documentation + /** + @dev Auth'ed function to add a quoter contract + @param quoter Address of the quoter contract + */ function addQuoter(address quoter) external auth { quoters.push(quoter); } + /** + @dev Auth'ed function to remove a quoter contract + @param index Index of the quoter contract to be removed + */ function removeQuoter(uint256 index) external auth { quoters[index] = quoters[quoters.length - 1]; quoters.pop(); } - function quote(address sell, address buy, uint256 amount) public returns (uint256 outAMount) { - for(uint256 i; i < quoters.length; i++) { - outAMount = _max(outAMount, Quoter(quoters[i]).quote(sell, buy, amount)); + // Note: although sell and buy tokens are passed there is no guarantee that the quoters will use/validate them + function _quote(uint256 amount) internal view returns (uint256 outAmount) { + for (uint256 i; i < quoters.length; i++) { + outAmount = _max(outAmount, IQuoter(quoters[i]).quote(sell, buy, amount)); } } @@ -157,7 +150,7 @@ contract Recipe2 is KilnBase { uint256 _halfIn = inAmount / 2; bytes memory _path = path; - uint256 _quote = quote(sell, buy, _halfIn); // TODO: check if can avoid re-read of sell and buy + uint256 quote = _quote(_halfIn); GemLike(sell).approve(uniV3Router, _halfIn); SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ @@ -165,18 +158,18 @@ contract Recipe2 is KilnBase { recipient: address(this), deadline: block.timestamp, amountIn: _halfIn, - amountOutMinimum: _quote * yen / WAD + amountOutMinimum: quote * yen / WAD }); uint256 bought = SwapRouterLike(uniV3Router).exactInput(params); // In case the `sell` token deposit amount needs to be insisted on it means the full `bought` amount of buy tokens are deposited. // Therefore we want at least the reference price (halfIn / quote) factored by zen. uint256 _zen = zen; - uint256 sellDepositMin = (bought * _halfIn / _quote) * _zen / WAD; + uint256 sellDepositMin = (bought * _halfIn / quote) * _zen / WAD; // In case the `buy` token deposit amount needs to be insisted on it means the full `halfIn` amount of sell tokens are deposited. // As `halflot` was also used in the quote calculation, it represents the exact reference price and only needs to be factored by zen - uint256 buyDepositMin = _quote * _zen / WAD; + uint256 buyDepositMin = quote * _zen / WAD; GemLike(sell).approve(uniV2Router, _halfIn); GemLike(buy).approve(uniV2Router, bought); diff --git a/src/quoters/IQuoter.sol b/src/quoters/IQuoter.sol new file mode 100644 index 0000000..68e2ba9 --- /dev/null +++ b/src/quoters/IQuoter.sol @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: © 2022 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.14; + +// Note: although sell and buy tokens are passes along there is no guarantee that the quoters will use/validate them +interface IQuoter { + function quote(address sell, address buy, uint256 amount) external view returns (uint256 outAMount); +} \ No newline at end of file diff --git a/src/quoters/QuoterTwapProduct.sol b/src/quoters/QuoterTwapProduct.sol index 51863be..05387f7 100644 --- a/src/quoters/QuoterTwapProduct.sol +++ b/src/quoters/QuoterTwapProduct.sol @@ -16,6 +16,7 @@ pragma solidity ^0.8.14; +import {IQuoter} from "src/quoters/IQuoter.sol"; import {FullMath} from "src/uniV3/FullMath.sol"; import {TickMath} from "src/uniV3/TickMath.sol"; import {PoolAddress} from "src/uniV3/PoolAddress.sol"; @@ -27,7 +28,7 @@ interface UniswapV3PoolLike { function observe(uint32[] calldata) external view returns (int56[] memory, uint160[] memory); } -contract QuoterTwapProduct { +contract QuoterTwapProduct is IQuoter { using Path for bytes; mapping (address => uint256) public wards; @@ -58,16 +59,26 @@ contract QuoterTwapProduct { function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } - // TODO: documentation + /** + @dev Auth'ed function to update scope + Warning - a low `scope` increases the susceptibility to oracle manipulation attacks + @param what Tag of value to update + @param data Value to update + */ function file(bytes32 what, uint256 data) external auth { if (what == "scope") { require(data > 0, "QuoterTwapProduct/zero-scope"); - require(data <= uint32(type(int32).max), "Recipe2/scope-overflow"); + require(data <= uint32(type(int32).max), "QuoterTwapProduct/scope-overflow"); scope = data; } else revert("QuoterTwapProduct/file-unrecognized-param"); emit File(what, data); } + /** + @dev Auth'ed function to update path value + @param what Tag of value to update + @param data Value to update + */ function file(bytes32 what, bytes calldata data) external auth { if (what == "path") path = data; else revert("QuoterTwapProduct/file-unrecognized-param"); diff --git a/src/quoters/QuoterTwapProduct.t.sol b/src/quoters/QuoterTwapProduct.t.sol index 4fac229..c133a16 100644 --- a/src/quoters/QuoterTwapProduct.t.sol +++ b/src/quoters/QuoterTwapProduct.t.sol @@ -61,6 +61,9 @@ contract QuoterTwapProductTest is Test { } } + event File(bytes32 indexed what, bytes data); + event File(bytes32 indexed what, uint256 data); + function setUp() public { quoter = Univ3Quoter(QUOTER); tpQuoter = new QuoterTwapProduct(UNIFACTORY); @@ -74,6 +77,37 @@ contract QuoterTwapProductTest is Test { tpQuoter.file("scope", scope); } + function testFilePath() public { + path = abi.encodePacked(DAI, uint24(100), USDC); + vm.expectEmit(true, true, false, false); + emit File(bytes32("path"), path); + tpQuoter.file("path", path); + assertEq0(tpQuoter.path(), path); + } + + function testFileScope() public { + vm.expectEmit(true, true, false, false); + emit File(bytes32("scope"), 314); + tpQuoter.file("scope", 314); + assertEq(tpQuoter.scope(), 314); + } + + function testFileZeroScope() public { + vm.expectRevert("QuoterTwapProduct/zero-scope"); + tpQuoter.file("scope", 0); + } + + function testFileScopeTooLarge() public { + vm.expectRevert("QuoterTwapProduct/scope-overflow"); + tpQuoter.file("scope", uint32(type(int32).max) + 1); + } + + function testFilePathNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("QuoterTwapProduct/not-authorized"); + tpQuoter.file("path", path); + } + function testSingleHopPath() public { bytes memory _path = abi.encodePacked(USDC, uint24(500), WETH); tpQuoter.file("path", _path); From 7113a1b2518445d9ea64d57beeadef9229cc497d Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Mon, 27 Feb 2023 11:15:24 +0200 Subject: [PATCH 14/19] Small changes, tests --- src/KilnUniV3.sol | 15 +++- src/KilnUniV3.t.sol | 101 ++++++++++++++++++++++- src/Recipe2.sol | 30 +++++-- src/Recipe2.t.sol | 123 ++++++++++++++++++++++------ src/quoters/IQuoter.sol | 2 +- src/quoters/QuoterTwapProduct.t.sol | 30 +++++++ 6 files changed, 265 insertions(+), 36 deletions(-) diff --git a/src/KilnUniV3.sol b/src/KilnUniV3.sol index 5a2a008..4cecbe0 100644 --- a/src/KilnUniV3.sol +++ b/src/KilnUniV3.sol @@ -45,6 +45,8 @@ contract KilnUniV3 is KilnBase { address public immutable receiver; event File(bytes32 indexed what, bytes data); + event AddQuoter(address indexed quoter); + event RemoveQuoter(uint256 indexed index, address indexed quoter); // @param _sell the contract address of the token that will be sold // @param _buy the contract address of the token that will be purchased @@ -102,6 +104,7 @@ contract KilnUniV3 is KilnBase { */ function addQuoter(address quoter) external auth { quoters.push(quoter); + emit AddQuoter(quoter); } /** @@ -109,13 +112,23 @@ contract KilnUniV3 is KilnBase { @param index Index of the quoter contract to be removed */ function removeQuoter(uint256 index) external auth { + address remove = quoters[index]; quoters[index] = quoters[quoters.length - 1]; quoters.pop(); + emit RemoveQuoter(index, remove); + } + + /** + @dev Get the amount of quoters + @return count Amount of quoters + */ + function quotersCount() external view returns(uint256 count) { + return quoters.length; } - // Note: although sell and buy tokens are passed there is no guarantee that the quoters will use/validate them function _quote(uint256 amount) internal view returns (uint256 outAmount) { for (uint256 i; i < quoters.length; i++) { + // Note: although sell and buy tokens are passed there is no guarantee that quoters will use/validate them outAmount = _max(outAmount, IQuoter(quoters[i]).quote(sell, buy, amount)); } } diff --git a/src/KilnUniV3.t.sol b/src/KilnUniV3.t.sol index 325fd77..b8e8686 100644 --- a/src/KilnUniV3.t.sol +++ b/src/KilnUniV3.t.sol @@ -34,6 +34,12 @@ interface Univ3Quoter { contract User {} +contract HighAmountQuoter is IQuoter { + function quote(address, address, uint256 amountIn) external pure returns (uint256 amountOut) { + return amountIn; // MKR / DAI = 1, much higher out amount than usual + } +} + contract KilnTest is Test { KilnUniV3 kiln; QuoterTwapProduct qtwap; @@ -55,6 +61,8 @@ contract KilnTest is Test { event File(bytes32 indexed what, bytes data); event File(bytes32 indexed what, uint256 data); + event AddQuoter(address indexed quoter); + event RemoveQuoter(uint256 indexed index, address indexed quoter); function setUp() public { user = new User(); @@ -141,10 +149,99 @@ contract KilnTest is Test { kiln.file("yen", 42); } - function testFileScopeNonAuthed() public { + function testAddRemoveQuoter() public { + // clean up quoters list + assertEq(kiln.quoters(0), address(qtwap)); + assertEq(kiln.quotersCount(), 1); + kiln.removeQuoter(0); + assertEq(kiln.quotersCount(), 0); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(1)); + kiln.addQuoter(address(1)); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quotersCount(), 1); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(2)); + kiln.addQuoter(address(2)); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quotersCount(), 2); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(3)); + kiln.addQuoter(address(3)); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quoters(2), address(3)); + assertEq(kiln.quotersCount(), 3); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(4)); + kiln.addQuoter(address(4)); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quoters(2), address(3)); + assertEq(kiln.quoters(3), address(4)); + assertEq(kiln.quotersCount(), 4); + + // Remove in the middle + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(2, address(3)); + kiln.removeQuoter(2); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quoters(2), address(4)); + assertEq(kiln.quotersCount(), 3); + + // Remove last + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(2, address(4)); + kiln.removeQuoter(2); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quotersCount(), 2); + + // Remove first + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(0, address(1)); + kiln.removeQuoter(0); + assertEq(kiln.quoters(0), address(2)); + assertEq(kiln.quotersCount(), 1); + + // Remove single + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(0, address(2)); + kiln.removeQuoter(0); + assertEq(kiln.quotersCount(), 0); + } + + function testAddQuoterNonAuthed() public { vm.startPrank(address(123)); vm.expectRevert("KilnBase/not-authorized"); - kiln.file("scope", 413); + kiln.addQuoter(address(7)); + } + + function testRemoveQuoterNonAuthed() public { + kiln.addQuoter(address(7)); + + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.removeQuoter(0); + } + + function testMultipleQuoters() public { + // Add a quoter with a higher amount than usual to act as reference + HighAmountQuoter q2 = new HighAmountQuoter(); + kiln.addQuoter(address(q2)); + + // Permissive values + kiln.file("yen", 50 * WAD / 100); + + deal(DAI, address(kiln), 50_000 * WAD); + vm.expectRevert("Too little received"); + kiln.fire(); } function testFireYenMuchLessThanTwap() public { diff --git a/src/Recipe2.sol b/src/Recipe2.sol index 8847174..aa35189 100644 --- a/src/Recipe2.sol +++ b/src/Recipe2.sol @@ -50,11 +50,11 @@ interface UniswapV2Router02Like { } contract Recipe2 is KilnBase { - uint256 public yen; // [WAD] Relative multiplier of the reference price to insist on in the UniV3 trade. - // For example: 0.98 * WAD allows 2% worse price than the reference. - uint256 public zen; // [WAD] Allowed Univ2 deposit price deviations from the reference price. Must be <= WAD - // For example: 0.97 * WAD allows 3% price deviation to either side. - bytes public path; // ABI-encoded UniV3 compatible path + uint256 public yen; // [WAD] Relative multiplier of the reference price to insist on in the UniV3 trade. + // For example: 0.98 * WAD allows 2% worse price than the reference. + uint256 public zen; // [WAD] Allowed Univ2 deposit price deviations from the reference price. Must be <= WAD + // For example: 0.97 * WAD allows 3% price deviation to either side. + bytes public path; // ABI-encoded UniV3 compatible path address[] public quoters; address public immutable uniV2Router; @@ -62,6 +62,8 @@ contract Recipe2 is KilnBase { address public immutable receiver; event File(bytes32 indexed what, bytes data); + event AddQuoter(address indexed quoter); + event RemoveQuoter(uint256 indexed index, address indexed quoter); // @param _sell the contract address of the token that will be sold // @param _buy the contract address of the token that will be purchased @@ -128,6 +130,7 @@ contract Recipe2 is KilnBase { */ function addQuoter(address quoter) external auth { quoters.push(quoter); + emit AddQuoter(quoter); } /** @@ -135,20 +138,29 @@ contract Recipe2 is KilnBase { @param index Index of the quoter contract to be removed */ function removeQuoter(uint256 index) external auth { + address remove = quoters[index]; quoters[index] = quoters[quoters.length - 1]; quoters.pop(); + emit RemoveQuoter(index, remove); + } + + /** + @dev Get the amount of quoters + @return count Amount of quoters + */ + function quotersCount() external view returns(uint256 count) { + return quoters.length; } - // Note: although sell and buy tokens are passed there is no guarantee that the quoters will use/validate them function _quote(uint256 amount) internal view returns (uint256 outAmount) { for (uint256 i; i < quoters.length; i++) { + // Note: although sell and buy tokens are passed there is no guarantee that quoters will use/validate them outAmount = _max(outAmount, IQuoter(quoters[i]).quote(sell, buy, amount)); } } - function _swap(uint256 inAmount) internal override returns (uint256 swapped) { - - uint256 _halfIn = inAmount / 2; + function _swap(uint256 amount) internal override returns (uint256 swapped) { + uint256 _halfIn = amount / 2; bytes memory _path = path; uint256 quote = _quote(_halfIn); diff --git a/src/Recipe2.t.sol b/src/Recipe2.t.sol index d921888..f2b1567 100644 --- a/src/Recipe2.t.sol +++ b/src/Recipe2.t.sol @@ -58,6 +58,12 @@ interface UniswapV2PairLike { contract User {} +contract HighAmountQuoter is IQuoter { + function quote(address, address, uint256 amountIn) external pure returns (uint256 amountOut) { + return amountIn; // MKR / DAI = 1, much higher out amount than usual + } +} + contract KilnTest is Test { using UniswapV2Library for *; @@ -90,6 +96,8 @@ contract KilnTest is Test { event File(bytes32 indexed what, bytes data); event File(bytes32 indexed what, uint256 data); + event AddQuoter(address indexed quoter); + event RemoveQuoter(uint256 indexed index, address indexed quoter); function setUp() public { user = new User(); @@ -260,27 +268,6 @@ contract KilnTest is Test { kiln.file("zen", 0); } - // TODO: move to new quoter - /* - function testFileScope() public { - vm.expectEmit(true, true, false, false); - emit File(bytes32("scope"), 314); - kiln.file("scope", 314); - assertEq(kiln.scope(), 314); - } - - function testFileZeroScope() public { - vm.expectRevert("Recipe2/zero-scope"); - kiln.file("scope", 0); - } - - - function testFileScopeTooLarge() public { - vm.expectRevert("Recipe2/scope-overflow"); - kiln.file("scope", uint32(type(int32).max) + 1); - } - */ - function testFileBytesUnrecognized() public { vm.expectRevert("Recipe2/file-unrecognized-param"); kiln.file("nonsense", bytes("")); @@ -309,10 +296,100 @@ contract KilnTest is Test { kiln.file("zen", 7); } - function testFileScopeNonAuthed() public { + function testAddRemoveQuoter() public { + // clean up quoters list + assertEq(kiln.quoters(0), address(qtwap)); + assertEq(kiln.quotersCount(), 1); + kiln.removeQuoter(0); + assertEq(kiln.quotersCount(), 0); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(1)); + kiln.addQuoter(address(1)); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quotersCount(), 1); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(2)); + kiln.addQuoter(address(2)); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quotersCount(), 2); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(3)); + kiln.addQuoter(address(3)); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quoters(2), address(3)); + assertEq(kiln.quotersCount(), 3); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(4)); + kiln.addQuoter(address(4)); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quoters(2), address(3)); + assertEq(kiln.quoters(3), address(4)); + assertEq(kiln.quotersCount(), 4); + + // Remove in the middle + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(2, address(3)); + kiln.removeQuoter(2); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quoters(2), address(4)); + assertEq(kiln.quotersCount(), 3); + + // Remove last + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(2, address(4)); + kiln.removeQuoter(2); + assertEq(kiln.quoters(0), address(1)); + assertEq(kiln.quoters(1), address(2)); + assertEq(kiln.quotersCount(), 2); + + // Remove first + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(0, address(1)); + kiln.removeQuoter(0); + assertEq(kiln.quoters(0), address(2)); + assertEq(kiln.quotersCount(), 1); + + // Remove single + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(0, address(2)); + kiln.removeQuoter(0); + assertEq(kiln.quotersCount(), 0); + } + + function testAddQuoterNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.addQuoter(address(7)); + } + + function testRemoveQuoterNonAuthed() public { + kiln.addQuoter(address(7)); + vm.startPrank(address(123)); vm.expectRevert("KilnBase/not-authorized"); - kiln.file("scope", 413); + kiln.removeQuoter(0); + } + + function testMultipleQuoters() public { + // Add a quoter with a higher amount than usual to act as reference + HighAmountQuoter q2 = new HighAmountQuoter(); + kiln.addQuoter(address(q2)); + + // Permissive values + kiln.file("yen", 50 * WAD / 100); + kiln.file("zen", 50 * WAD / 100); + + deal(DAI, address(kiln), 50_000 * WAD); + vm.expectRevert("Too little received"); + kiln.fire(); } /* diff --git a/src/quoters/IQuoter.sol b/src/quoters/IQuoter.sol index 68e2ba9..6acbae9 100644 --- a/src/quoters/IQuoter.sol +++ b/src/quoters/IQuoter.sol @@ -16,7 +16,7 @@ pragma solidity ^0.8.14; -// Note: although sell and buy tokens are passes along there is no guarantee that the quoters will use/validate them +// Note: although sell and buy tokens are passed there is no guarantee that quoters will use/validate them interface IQuoter { function quote(address sell, address buy, uint256 amount) external view returns (uint256 outAMount); } \ No newline at end of file diff --git a/src/quoters/QuoterTwapProduct.t.sol b/src/quoters/QuoterTwapProduct.t.sol index c133a16..6a95d07 100644 --- a/src/quoters/QuoterTwapProduct.t.sol +++ b/src/quoters/QuoterTwapProduct.t.sol @@ -61,6 +61,8 @@ contract QuoterTwapProductTest is Test { } } + event Rely(address indexed usr); + event Deny(address indexed usr); event File(bytes32 indexed what, bytes data); event File(bytes32 indexed what, uint256 data); @@ -77,6 +79,34 @@ contract QuoterTwapProductTest is Test { tpQuoter.file("scope", scope); } + function testRely() public { + assertEq(tpQuoter.wards(address(123)), 0); + vm.expectEmit(true, false, false, false); + emit Rely(address(123)); + tpQuoter.rely(address(123)); + assertEq(tpQuoter.wards(address(123)), 1); + } + + function testDeny() public { + assertEq(tpQuoter.wards(address(this)), 1); + vm.expectEmit(true, false, false, false); + emit Deny(address(this)); + tpQuoter.deny(address(this)); + assertEq(tpQuoter.wards(address(this)), 0); + } + + function testRelyNonAuthed() public { + tpQuoter.deny(address(this)); + vm.expectRevert("QuoterTwapProduct/not-authorized"); + tpQuoter.rely(address(123)); + } + + function testDenyNonAuthed() public { + tpQuoter.deny(address(this)); + vm.expectRevert("QuoterTwapProduct/not-authorized"); + tpQuoter.deny(address(123)); + } + function testFilePath() public { path = abi.encodePacked(DAI, uint24(100), USDC); vm.expectEmit(true, true, false, false); From 673afbcd85cf6497d94f2df0ee11c8db30706a04 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Mon, 27 Feb 2023 11:22:05 +0200 Subject: [PATCH 15/19] Recipe2 => KilnUniV3SwapUniv2LP --- src/{Recipe2.sol => KilnUniV3SwapUniv2LP.sol} | 8 ++++---- src/{Recipe2.t.sol => KilnUniV3SwapUniv2LP.t.sol} | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) rename src/{Recipe2.sol => KilnUniV3SwapUniv2LP.sol} (97%) rename src/{Recipe2.t.sol => KilnUniV3SwapUniv2LP.t.sol} (98%) diff --git a/src/Recipe2.sol b/src/KilnUniV3SwapUniv2LP.sol similarity index 97% rename from src/Recipe2.sol rename to src/KilnUniV3SwapUniv2LP.sol index aa35189..aa943bc 100644 --- a/src/Recipe2.sol +++ b/src/KilnUniV3SwapUniv2LP.sol @@ -49,7 +49,7 @@ interface UniswapV2Router02Like { ) external returns (uint256 amountA, uint256 amountB, uint256 liquidity); } -contract Recipe2 is KilnBase { +contract KilnUniV3SwapUniv2LP is KilnBase { uint256 public yen; // [WAD] Relative multiplier of the reference price to insist on in the UniV3 trade. // For example: 0.98 * WAD allows 2% worse price than the reference. uint256 public zen; // [WAD] Allowed Univ2 deposit price deviations from the reference price. Must be <= WAD @@ -100,7 +100,7 @@ contract Recipe2 is KilnBase { */ function file(bytes32 what, bytes calldata data) external auth { if (what == "path") path = data; - else revert("Recipe2/file-unrecognized-param"); + else revert("KilnUniV3SwapUniv2LP/file-unrecognized-param"); emit File(what, data); } @@ -112,10 +112,10 @@ contract Recipe2 is KilnBase { */ function file(bytes32 what, uint256 data) public override auth { if (what == "yen") { - require(data > 0, "Recipe2/zero-yen"); + require(data > 0, "KilnUniV3SwapUniv2LP/zero-yen"); yen = data; } else if (what == "zen") { - require(data > 0, "Recipe2/zero-zen"); + require(data > 0, "KilnUniV3SwapUniv2LP/zero-zen"); zen = data; } else { super.file(what, data); diff --git a/src/Recipe2.t.sol b/src/KilnUniV3SwapUniv2LP.t.sol similarity index 98% rename from src/Recipe2.t.sol rename to src/KilnUniV3SwapUniv2LP.t.sol index f2b1567..a9c6f3e 100644 --- a/src/Recipe2.t.sol +++ b/src/KilnUniV3SwapUniv2LP.t.sol @@ -17,7 +17,7 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; -import "src/Recipe2.sol"; +import "src/KilnUniV3SwapUniv2LP.sol"; import "src/quoters/QuoterTwapProduct.sol"; import "src/uniV2/UniswapV2Library.sol"; @@ -68,7 +68,7 @@ contract KilnTest is Test { using UniswapV2Library for *; - Recipe2 kiln; + KilnUniV3SwapUniv2LP kiln; QuoterTwapProduct qtwap; Univ3Quoter univ3Quoter; User user; @@ -103,7 +103,7 @@ contract KilnTest is Test { user = new User(); path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); - kiln = new Recipe2(DAI, MKR, UNIV2ROUTER, UNIV3ROUTER, address(user)); + kiln = new KilnUniV3SwapUniv2LP(DAI, MKR, UNIV2ROUTER, UNIV3ROUTER, address(user)); univ3Quoter = Univ3Quoter(UNIV3QUOTER); pairToken = UniswapV2Library.pairFor(ExtendedUni2Router(UNIV2ROUTER).factory(), DAI, MKR); @@ -259,17 +259,17 @@ contract KilnTest is Test { } function testFileYenZero() public { - vm.expectRevert("Recipe2/zero-yen"); + vm.expectRevert("KilnUniV3SwapUniv2LP/zero-yen"); kiln.file("yen", 0); } function testFileZenZero() public { - vm.expectRevert("Recipe2/zero-zen"); + vm.expectRevert("KilnUniV3SwapUniv2LP/zero-zen"); kiln.file("zen", 0); } function testFileBytesUnrecognized() public { - vm.expectRevert("Recipe2/file-unrecognized-param"); + vm.expectRevert("KilnUniV3SwapUniv2LP/file-unrecognized-param"); kiln.file("nonsense", bytes("")); } From b5bcbfd08293727d78cb78ca8febdd1603e9eece Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Tue, 14 Mar 2023 14:48:38 +0200 Subject: [PATCH 16/19] Add max aggregator outside of kiln --- src/KilnUniV3.sol | 50 ++++----------- src/KilnUniV3.t.sol | 109 ++++++++------------------------ src/KilnUniV3SwapUniv2LP.sol | 53 +++++----------- src/KilnUniV3SwapUniv2LP.t.sol | 112 ++++++++------------------------- 4 files changed, 76 insertions(+), 248 deletions(-) diff --git a/src/KilnUniV3.sol b/src/KilnUniV3.sol index 4cecbe0..697a0d3 100644 --- a/src/KilnUniV3.sol +++ b/src/KilnUniV3.sol @@ -39,14 +39,13 @@ contract KilnUniV3 is KilnBase { uint256 public yen; // [WAD] Relative multiplier of the reference price to insist on in the UniV3 trade. // For example: 0.98 * WAD allows 2% worse price than the reference. bytes public path; // ABI-encoded UniV3 compatible path - address[] public quoters; + address public quoter; address public immutable uniV3Router; address public immutable receiver; + event File(bytes32 indexed what, address data); event File(bytes32 indexed what, bytes data); - event AddQuoter(address indexed quoter); - event RemoveQuoter(uint256 indexed index, address indexed quoter); // @param _sell the contract address of the token that will be sold // @param _buy the contract address of the token that will be purchased @@ -72,6 +71,12 @@ contract KilnUniV3 is KilnBase { z = x >= y ? x : y; } + function file(bytes32 what, address data) public virtual auth { + if (what == "quoter") quoter = data; + else revert("KilnUniV3/file-unrecognized-param"); + emit File(what, data); + } + /** @dev Auth'ed function to update path value @param what Tag of value to update @@ -98,48 +103,15 @@ contract KilnUniV3 is KilnBase { emit File(what, data); } - /** - @dev Auth'ed function to add a quoter contract - @param quoter Address of the quoter contract - */ - function addQuoter(address quoter) external auth { - quoters.push(quoter); - emit AddQuoter(quoter); - } - - /** - @dev Auth'ed function to remove a quoter contract - @param index Index of the quoter contract to be removed - */ - function removeQuoter(uint256 index) external auth { - address remove = quoters[index]; - quoters[index] = quoters[quoters.length - 1]; - quoters.pop(); - emit RemoveQuoter(index, remove); - } - - /** - @dev Get the amount of quoters - @return count Amount of quoters - */ - function quotersCount() external view returns(uint256 count) { - return quoters.length; - } - - function _quote(uint256 amount) internal view returns (uint256 outAmount) { - for (uint256 i; i < quoters.length; i++) { - // Note: although sell and buy tokens are passed there is no guarantee that quoters will use/validate them - outAmount = _max(outAmount, IQuoter(quoters[i]).quote(sell, buy, amount)); - } - } - function _swap(uint256 amount) internal override returns (uint256 swapped) { GemLike(sell).approve(uniV3Router, amount); bytes memory _path = path; uint256 _yen = yen; - uint256 amountMin = (_yen != 0) ? _quote(amount) * _yen / WAD : 0; + uint256 amountMin = (_yen != 0) ? + IQuoter(quoter).quote(sell, buy, amount) * _yen / WAD : + 0; ExactInputParams memory params = ExactInputParams({ path: _path, diff --git a/src/KilnUniV3.t.sol b/src/KilnUniV3.t.sol index b8e8686..d54432d 100644 --- a/src/KilnUniV3.t.sol +++ b/src/KilnUniV3.t.sol @@ -18,6 +18,7 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; import "src/KilnUniV3.sol"; +import "src/quoters/MaxAggregator.sol"; import "src/quoters/QuoterTwapProduct.sol"; interface TestGem { @@ -42,6 +43,7 @@ contract HighAmountQuoter is IQuoter { contract KilnTest is Test { KilnUniV3 kiln; + MaxAggregator aggregator; QuoterTwapProduct qtwap; Univ3Quoter quoter; User user; @@ -59,6 +61,7 @@ contract KilnTest is Test { address constant QUOTER = 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6; address constant FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + event File(bytes32 indexed what, address data); event File(bytes32 indexed what, bytes data); event File(bytes32 indexed what, uint256 data); event AddQuoter(address indexed quoter); @@ -69,6 +72,7 @@ contract KilnTest is Test { path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); kiln = new KilnUniV3(DAI, MKR, ROUTER, address(user)); + aggregator = new MaxAggregator(); quoter = Univ3Quoter(QUOTER); kiln.file("lot", 50_000 * WAD); @@ -77,7 +81,8 @@ contract KilnTest is Test { qtwap = new QuoterTwapProduct(FACTORY); qtwap.file("path", path); - kiln.addQuoter(address(qtwap)); + aggregator.addQuoter(address(qtwap)); + kiln.file("quoter", address(aggregator)); kiln.file("yen", 50 * WAD / 100); // Insist on very little on default } @@ -112,6 +117,13 @@ contract KilnTest is Test { SwapRouterLike(kiln.uniV3Router()).exactInput(params); } + function testFileQuoter() public { + vm.expectEmit(true, true, false, false); + emit File(bytes32("quoter"), address(314)); + kiln.file("quoter", address(314)); + assertEq(kiln.quoter(), address(314)); + } + function testFilePath() public { path = abi.encodePacked(DAI, uint24(100), USDC); vm.expectEmit(true, true, false, false); @@ -127,6 +139,11 @@ contract KilnTest is Test { assertEq(kiln.yen(), 42); } + function testFileAddressUnrecognized() public { + vm.expectRevert("KilnUniV3/file-unrecognized-param"); + kiln.file("nonsense", address(314)); + } + function testFileBytesUnrecognized() public { vm.expectRevert("KilnUniV3/file-unrecognized-param"); kiln.file("nonsense", bytes("")); @@ -137,104 +154,28 @@ contract KilnTest is Test { kiln.file("nonsense", 23); } - function testFilePathNonAuthed() public { - vm.startPrank(address(123)); - vm.expectRevert("KilnBase/not-authorized"); - kiln.file("path", path); - } - - function testFileYenNonAuthed() public { + function testFileQuoterNonAuthed() public { vm.startPrank(address(123)); vm.expectRevert("KilnBase/not-authorized"); - kiln.file("yen", 42); + kiln.file("quoter", address(314)); } - function testAddRemoveQuoter() public { - // clean up quoters list - assertEq(kiln.quoters(0), address(qtwap)); - assertEq(kiln.quotersCount(), 1); - kiln.removeQuoter(0); - assertEq(kiln.quotersCount(), 0); - - vm.expectEmit(true, true, false, false); - emit AddQuoter(address(1)); - kiln.addQuoter(address(1)); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quotersCount(), 1); - - vm.expectEmit(true, true, false, false); - emit AddQuoter(address(2)); - kiln.addQuoter(address(2)); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quotersCount(), 2); - - vm.expectEmit(true, true, false, false); - emit AddQuoter(address(3)); - kiln.addQuoter(address(3)); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quoters(2), address(3)); - assertEq(kiln.quotersCount(), 3); - - vm.expectEmit(true, true, false, false); - emit AddQuoter(address(4)); - kiln.addQuoter(address(4)); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quoters(2), address(3)); - assertEq(kiln.quoters(3), address(4)); - assertEq(kiln.quotersCount(), 4); - - // Remove in the middle - vm.expectEmit(true, true, false, false); - emit RemoveQuoter(2, address(3)); - kiln.removeQuoter(2); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quoters(2), address(4)); - assertEq(kiln.quotersCount(), 3); - - // Remove last - vm.expectEmit(true, true, false, false); - emit RemoveQuoter(2, address(4)); - kiln.removeQuoter(2); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quotersCount(), 2); - - // Remove first - vm.expectEmit(true, true, false, false); - emit RemoveQuoter(0, address(1)); - kiln.removeQuoter(0); - assertEq(kiln.quoters(0), address(2)); - assertEq(kiln.quotersCount(), 1); - - // Remove single - vm.expectEmit(true, true, false, false); - emit RemoveQuoter(0, address(2)); - kiln.removeQuoter(0); - assertEq(kiln.quotersCount(), 0); - } - - function testAddQuoterNonAuthed() public { + function testFilePathNonAuthed() public { vm.startPrank(address(123)); vm.expectRevert("KilnBase/not-authorized"); - kiln.addQuoter(address(7)); + kiln.file("path", path); } - function testRemoveQuoterNonAuthed() public { - kiln.addQuoter(address(7)); - + function testFileYenNonAuthed() public { vm.startPrank(address(123)); vm.expectRevert("KilnBase/not-authorized"); - kiln.removeQuoter(0); + kiln.file("yen", 42); } function testMultipleQuoters() public { // Add a quoter with a higher amount than usual to act as reference HighAmountQuoter q2 = new HighAmountQuoter(); - kiln.addQuoter(address(q2)); + aggregator.addQuoter(address(q2)); // Permissive values kiln.file("yen", 50 * WAD / 100); diff --git a/src/KilnUniV3SwapUniv2LP.sol b/src/KilnUniV3SwapUniv2LP.sol index aa943bc..f768227 100644 --- a/src/KilnUniV3SwapUniv2LP.sol +++ b/src/KilnUniV3SwapUniv2LP.sol @@ -55,15 +55,14 @@ contract KilnUniV3SwapUniv2LP is KilnBase { uint256 public zen; // [WAD] Allowed Univ2 deposit price deviations from the reference price. Must be <= WAD // For example: 0.97 * WAD allows 3% price deviation to either side. bytes public path; // ABI-encoded UniV3 compatible path - address[] public quoters; + address public quoter; address public immutable uniV2Router; address public immutable uniV3Router; address public immutable receiver; + event File(bytes32 indexed what, address data); event File(bytes32 indexed what, bytes data); - event AddQuoter(address indexed quoter); - event RemoveQuoter(uint256 indexed index, address indexed quoter); // @param _sell the contract address of the token that will be sold // @param _buy the contract address of the token that will be purchased @@ -93,6 +92,17 @@ contract KilnUniV3SwapUniv2LP is KilnBase { z = x >= y ? x : y; } + /** + @dev Auth'ed function to update quoter address value + @param what Tag of value to update + @param data Value to update + */ + function file(bytes32 what, address data) public virtual auth { + if (what == "quoter") quoter = data; + else revert("KilnUniV3/file-unrecognized-param"); + emit File(what, data); + } + /** @dev Auth'ed function to update path value @param what Tag of value to update @@ -124,45 +134,10 @@ contract KilnUniV3SwapUniv2LP is KilnBase { emit File(what, data); } - /** - @dev Auth'ed function to add a quoter contract - @param quoter Address of the quoter contract - */ - function addQuoter(address quoter) external auth { - quoters.push(quoter); - emit AddQuoter(quoter); - } - - /** - @dev Auth'ed function to remove a quoter contract - @param index Index of the quoter contract to be removed - */ - function removeQuoter(uint256 index) external auth { - address remove = quoters[index]; - quoters[index] = quoters[quoters.length - 1]; - quoters.pop(); - emit RemoveQuoter(index, remove); - } - - /** - @dev Get the amount of quoters - @return count Amount of quoters - */ - function quotersCount() external view returns(uint256 count) { - return quoters.length; - } - - function _quote(uint256 amount) internal view returns (uint256 outAmount) { - for (uint256 i; i < quoters.length; i++) { - // Note: although sell and buy tokens are passed there is no guarantee that quoters will use/validate them - outAmount = _max(outAmount, IQuoter(quoters[i]).quote(sell, buy, amount)); - } - } - function _swap(uint256 amount) internal override returns (uint256 swapped) { uint256 _halfIn = amount / 2; bytes memory _path = path; - uint256 quote = _quote(_halfIn); + uint256 quote = IQuoter(quoter).quote(sell, buy, amount); GemLike(sell).approve(uniV3Router, _halfIn); SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ diff --git a/src/KilnUniV3SwapUniv2LP.t.sol b/src/KilnUniV3SwapUniv2LP.t.sol index a9c6f3e..a703f7f 100644 --- a/src/KilnUniV3SwapUniv2LP.t.sol +++ b/src/KilnUniV3SwapUniv2LP.t.sol @@ -19,6 +19,7 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; import "src/KilnUniV3SwapUniv2LP.sol"; import "src/quoters/QuoterTwapProduct.sol"; +import "src/quoters/MaxAggregator.sol"; import "src/uniV2/UniswapV2Library.sol"; import "src/uniV2/IUniswapV2Pair.sol"; @@ -71,6 +72,7 @@ contract KilnTest is Test { KilnUniV3SwapUniv2LP kiln; QuoterTwapProduct qtwap; Univ3Quoter univ3Quoter; + MaxAggregator aggregator; User user; uint256 halfLot; @@ -94,16 +96,16 @@ contract KilnTest is Test { address constant UNIV3QUOTER = 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6; address constant UNIV3FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984; + event File(bytes32 indexed what, address data); event File(bytes32 indexed what, bytes data); event File(bytes32 indexed what, uint256 data); - event AddQuoter(address indexed quoter); - event RemoveQuoter(uint256 indexed index, address indexed quoter); function setUp() public { user = new User(); path = abi.encodePacked(DAI, uint24(100), USDC, uint24(500), WETH, uint24(3000), MKR); kiln = new KilnUniV3SwapUniv2LP(DAI, MKR, UNIV2ROUTER, UNIV3ROUTER, address(user)); + aggregator = new MaxAggregator(); univ3Quoter = Univ3Quoter(UNIV3QUOTER); pairToken = UniswapV2Library.pairFor(ExtendedUni2Router(UNIV2ROUTER).factory(), DAI, MKR); @@ -114,7 +116,9 @@ contract KilnTest is Test { qtwap = new QuoterTwapProduct(UNIV3FACTORY); qtwap.file("path", path); - kiln.addQuoter(address(qtwap)); + + aggregator.addQuoter(address(qtwap)); + kiln.file("quoter", address(aggregator)); // When changing univ3 price we'll have to relate to half lot amount, as that's what fire() trades there refHalfLot = getRefOutAMount(halfLot); @@ -236,6 +240,13 @@ contract KilnTest is Test { assert(current >= minOutAmount && current <= maxOutAMount); } + function testFileQuoter() public { + vm.expectEmit(true, true, false, false); + emit File(bytes32("quoter"), address(314)); + kiln.file("quoter", address(314)); + assertEq(kiln.quoter(), address(314)); + } + function testFilePath() public { path = abi.encodePacked(DAI, uint24(100), USDC); vm.expectEmit(true, true, false, false); @@ -268,6 +279,11 @@ contract KilnTest is Test { kiln.file("zen", 0); } + function testFileAddressUnrecognized() public { + vm.expectRevert("KilnUniV3/file-unrecognized-param"); + kiln.file("nonsense", address(314)); + } + function testFileBytesUnrecognized() public { vm.expectRevert("KilnUniV3SwapUniv2LP/file-unrecognized-param"); kiln.file("nonsense", bytes("")); @@ -278,6 +294,12 @@ contract KilnTest is Test { kiln.file("nonsense", 23); } + function testFileQuoterNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("KilnBase/not-authorized"); + kiln.file("quoter", address(314)); + } + function testFilePathNonAuthed() public { vm.startPrank(address(123)); vm.expectRevert("KilnBase/not-authorized"); @@ -296,92 +318,10 @@ contract KilnTest is Test { kiln.file("zen", 7); } - function testAddRemoveQuoter() public { - // clean up quoters list - assertEq(kiln.quoters(0), address(qtwap)); - assertEq(kiln.quotersCount(), 1); - kiln.removeQuoter(0); - assertEq(kiln.quotersCount(), 0); - - vm.expectEmit(true, true, false, false); - emit AddQuoter(address(1)); - kiln.addQuoter(address(1)); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quotersCount(), 1); - - vm.expectEmit(true, true, false, false); - emit AddQuoter(address(2)); - kiln.addQuoter(address(2)); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quotersCount(), 2); - - vm.expectEmit(true, true, false, false); - emit AddQuoter(address(3)); - kiln.addQuoter(address(3)); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quoters(2), address(3)); - assertEq(kiln.quotersCount(), 3); - - vm.expectEmit(true, true, false, false); - emit AddQuoter(address(4)); - kiln.addQuoter(address(4)); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quoters(2), address(3)); - assertEq(kiln.quoters(3), address(4)); - assertEq(kiln.quotersCount(), 4); - - // Remove in the middle - vm.expectEmit(true, true, false, false); - emit RemoveQuoter(2, address(3)); - kiln.removeQuoter(2); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quoters(2), address(4)); - assertEq(kiln.quotersCount(), 3); - - // Remove last - vm.expectEmit(true, true, false, false); - emit RemoveQuoter(2, address(4)); - kiln.removeQuoter(2); - assertEq(kiln.quoters(0), address(1)); - assertEq(kiln.quoters(1), address(2)); - assertEq(kiln.quotersCount(), 2); - - // Remove first - vm.expectEmit(true, true, false, false); - emit RemoveQuoter(0, address(1)); - kiln.removeQuoter(0); - assertEq(kiln.quoters(0), address(2)); - assertEq(kiln.quotersCount(), 1); - - // Remove single - vm.expectEmit(true, true, false, false); - emit RemoveQuoter(0, address(2)); - kiln.removeQuoter(0); - assertEq(kiln.quotersCount(), 0); - } - - function testAddQuoterNonAuthed() public { - vm.startPrank(address(123)); - vm.expectRevert("KilnBase/not-authorized"); - kiln.addQuoter(address(7)); - } - - function testRemoveQuoterNonAuthed() public { - kiln.addQuoter(address(7)); - - vm.startPrank(address(123)); - vm.expectRevert("KilnBase/not-authorized"); - kiln.removeQuoter(0); - } - function testMultipleQuoters() public { // Add a quoter with a higher amount than usual to act as reference HighAmountQuoter q2 = new HighAmountQuoter(); - kiln.addQuoter(address(q2)); + aggregator.addQuoter(address(q2)); // Permissive values kiln.file("yen", 50 * WAD / 100); From 4959baa1292be5bb7d972b20f578369526a21868 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Tue, 14 Mar 2023 14:54:49 +0200 Subject: [PATCH 17/19] Commit also MaxAggregator --- src/quoters/MaxAggregator.sol | 90 +++++++++++++++++++++ src/quoters/MaxAggregator.t.sol | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/quoters/MaxAggregator.sol create mode 100644 src/quoters/MaxAggregator.t.sol diff --git a/src/quoters/MaxAggregator.sol b/src/quoters/MaxAggregator.sol new file mode 100644 index 0000000..a2aeae1 --- /dev/null +++ b/src/quoters/MaxAggregator.sol @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: © 2022 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.14; + +import {IQuoter} from "src/quoters/IQuoter.sol"; + +contract MaxAggregator is IQuoter { + mapping (address => uint256) public wards; + address[] public quoters; + + event Rely(address indexed usr); + event Deny(address indexed usr); + event AddQuoter(address indexed quoter); + event RemoveQuoter(uint256 indexed index, address indexed quoter); + + constructor() { + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + modifier auth { + require(wards[msg.sender] == 1, "MaxAggregator/not-authorized"); + _; + } + + function _max(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = x >= y ? x : y; + } + + /** + @dev Auth'ed function to authorize an address for privileged functions + @param usr Address to be authorized + */ + function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } + + /** + @dev Auth'ed function to un-authorize an address for privileged functions + @param usr Address to be un-authorized + */ + function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } + + /** + @dev Auth'ed function to add a quoter contract + @param quoter Address of the quoter contract + */ + function addQuoter(address quoter) external auth { + quoters.push(quoter); + emit AddQuoter(quoter); + } + + /** + @dev Auth'ed function to remove a quoter contract + @param index Index of the quoter contract to be removed + */ + function removeQuoter(uint256 index) external auth { + address remove = quoters[index]; + quoters[index] = quoters[quoters.length - 1]; + quoters.pop(); + emit RemoveQuoter(index, remove); + } + + /** + @dev Get the amount of quoters + @return count Amount of quoters + */ + function quotersCount() external view returns(uint256 count) { + return quoters.length; + } + + function quote(address sell, address buy, uint256 amount) external view returns (uint256 outAmount) { + for (uint256 i; i < quoters.length; i++) { + // Note: although sell and buy tokens are passed there is no guarantee that quoters will use/validate them + outAmount = _max(outAmount, IQuoter(quoters[i]).quote(sell, buy, amount)); + } + } +} diff --git a/src/quoters/MaxAggregator.t.sol b/src/quoters/MaxAggregator.t.sol new file mode 100644 index 0000000..045d32c --- /dev/null +++ b/src/quoters/MaxAggregator.t.sol @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: © 2022 Dai Foundation +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.14; + +import "forge-std/Test.sol"; +import "src/quoters/MaxAggregator.sol"; + +contract AffregatorTest is Test { + MaxAggregator aggregator; + + event Rely(address indexed usr); + event Deny(address indexed usr); + event AddQuoter(address indexed quoter); + event RemoveQuoter(uint256 indexed index, address indexed quoter); + + function setUp() public { + aggregator = new MaxAggregator(); + } + + function testRely() public { + assertEq(aggregator.wards(address(123)), 0); + vm.expectEmit(true, false, false, false); + emit Rely(address(123)); + aggregator.rely(address(123)); + assertEq(aggregator.wards(address(123)), 1); + } + + function testDeny() public { + assertEq(aggregator.wards(address(this)), 1); + vm.expectEmit(true, false, false, false); + emit Deny(address(this)); + aggregator.deny(address(this)); + assertEq(aggregator.wards(address(this)), 0); + } + + function testRelyNonAuthed() public { + aggregator.deny(address(this)); + vm.expectRevert("MaxAggregator/not-authorized"); + aggregator.rely(address(123)); + } + + function testDenyNonAuthed() public { + aggregator.deny(address(this)); + vm.expectRevert("MaxAggregator/not-authorized"); + aggregator.deny(address(123)); + } + + function testAddRemoveQuoter() public { + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(1)); + aggregator.addQuoter(address(1)); + assertEq(aggregator.quoters(0), address(1)); + assertEq(aggregator.quotersCount(), 1); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(2)); + aggregator.addQuoter(address(2)); + assertEq(aggregator.quoters(0), address(1)); + assertEq(aggregator.quoters(1), address(2)); + assertEq(aggregator.quotersCount(), 2); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(3)); + aggregator.addQuoter(address(3)); + assertEq(aggregator.quoters(0), address(1)); + assertEq(aggregator.quoters(1), address(2)); + assertEq(aggregator.quoters(2), address(3)); + assertEq(aggregator.quotersCount(), 3); + + vm.expectEmit(true, true, false, false); + emit AddQuoter(address(4)); + aggregator.addQuoter(address(4)); + assertEq(aggregator.quoters(0), address(1)); + assertEq(aggregator.quoters(1), address(2)); + assertEq(aggregator.quoters(2), address(3)); + assertEq(aggregator.quoters(3), address(4)); + assertEq(aggregator.quotersCount(), 4); + + // Remove in the middle + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(2, address(3)); + aggregator.removeQuoter(2); + assertEq(aggregator.quoters(0), address(1)); + assertEq(aggregator.quoters(1), address(2)); + assertEq(aggregator.quoters(2), address(4)); + assertEq(aggregator.quotersCount(), 3); + + // Remove last + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(2, address(4)); + aggregator.removeQuoter(2); + assertEq(aggregator.quoters(0), address(1)); + assertEq(aggregator.quoters(1), address(2)); + assertEq(aggregator.quotersCount(), 2); + + // Remove first + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(0, address(1)); + aggregator.removeQuoter(0); + assertEq(aggregator.quoters(0), address(2)); + assertEq(aggregator.quotersCount(), 1); + + // Remove single + vm.expectEmit(true, true, false, false); + emit RemoveQuoter(0, address(2)); + aggregator.removeQuoter(0); + assertEq(aggregator.quotersCount(), 0); + } + + function testAddQuoterNonAuthed() public { + vm.startPrank(address(123)); + vm.expectRevert("MaxAggregator/not-authorized"); + aggregator.addQuoter(address(7)); + } + + function testRemoveQuoterNonAuthed() public { + aggregator.addQuoter(address(7)); + + vm.startPrank(address(123)); + vm.expectRevert("MaxAggregator/not-authorized"); + aggregator.removeQuoter(0); + } +} From 1cc29963bcb16c99829d87af0de393f49e55a9f9 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Tue, 14 Mar 2023 15:37:44 +0200 Subject: [PATCH 18/19] Fix amount => _halfIn --- src/KilnUniV3SwapUniv2LP.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/KilnUniV3SwapUniv2LP.sol b/src/KilnUniV3SwapUniv2LP.sol index f768227..ebaaa10 100644 --- a/src/KilnUniV3SwapUniv2LP.sol +++ b/src/KilnUniV3SwapUniv2LP.sol @@ -137,7 +137,7 @@ contract KilnUniV3SwapUniv2LP is KilnBase { function _swap(uint256 amount) internal override returns (uint256 swapped) { uint256 _halfIn = amount / 2; bytes memory _path = path; - uint256 quote = IQuoter(quoter).quote(sell, buy, amount); + uint256 quote = IQuoter(quoter).quote(sell, buy, _halfIn); GemLike(sell).approve(uniV3Router, _halfIn); SwapRouterLike.ExactInputParams memory params = SwapRouterLike.ExactInputParams({ From d1cc9929dcdfd51ce94fc1a53ae329b9f1a19fe1 Mon Sep 17 00:00:00 2001 From: rockyfour <115617270+rockyfour@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:52:37 +0200 Subject: [PATCH 19/19] Use explicit price check for the V2 deposit instead of relying on the router Previousluy the v2 deposit price limits check used amountAMin and amountBMin. That has been changed to an explicit price check after the deposit. The reason the previous v2 deposit price limits check is not good enough is that it uses amountADesired/amountBDesired its reference price and not the quote reference price as we would have wanted. The code did attemp to make up for that by "shifting" amountAmin and amountBmin to center around the quote reference, but that doesn't fully work since the v2 router price limit check validates only one of the min or max prices and not both. The router chooses whether to validate the min or max price according to the direction that the price moves vs the price of amountADesired / amountBDesired (which is in our case the v3 price we got on the first swap). Can see that in the amountAMin and amountBMin parameters documentation here: https://docs.uniswap.org/contracts/v2/reference/smart-contracts/router-02#addliquidity Thus there would be no limit enforcemenet for some situations. For example: [ <-quote-> ] v3 v2 Where `quote` represent the quote reference price, the range around it is `zen`, v3 is the price we got on univ3 and v2 is the v2 deposit price. In such a case since the v2 price is out of the zen range the tx should fail, but since the price went up in comparison to v3, the price check would only be to the down side (and would therefore succeed). --- src/KilnUniV3SwapUniv2LP.sol | 25 +++++++--------- src/KilnUniV3SwapUniv2LP.t.sol | 53 ++++++++++++++++----------------- src/quoters/MaxAggregator.t.sol | 2 +- 3 files changed, 38 insertions(+), 42 deletions(-) diff --git a/src/KilnUniV3SwapUniv2LP.sol b/src/KilnUniV3SwapUniv2LP.sol index ebaaa10..34be2c8 100644 --- a/src/KilnUniV3SwapUniv2LP.sol +++ b/src/KilnUniV3SwapUniv2LP.sol @@ -149,32 +149,29 @@ contract KilnUniV3SwapUniv2LP is KilnBase { }); uint256 bought = SwapRouterLike(uniV3Router).exactInput(params); - // In case the `sell` token deposit amount needs to be insisted on it means the full `bought` amount of buy tokens are deposited. - // Therefore we want at least the reference price (halfIn / quote) factored by zen. - uint256 _zen = zen; - uint256 sellDepositMin = (bought * _halfIn / quote) * _zen / WAD; - - // In case the `buy` token deposit amount needs to be insisted on it means the full `halfIn` amount of sell tokens are deposited. - // As `halflot` was also used in the quote calculation, it represents the exact reference price and only needs to be factored by zen - uint256 buyDepositMin = quote * _zen / WAD; - GemLike(sell).approve(uniV2Router, _halfIn); GemLike(buy).approve(uniV2Router, bought); - (, uint256 amountB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ + (uint256 depositA, uint256 depositB, uint256 liquidity) = UniswapV2Router02Like(uniV2Router).addLiquidity({ tokenA: sell, tokenB: buy, amountADesired: _halfIn, amountBDesired: bought, - amountAMin: sellDepositMin, - amountBMin: buyDepositMin, + amountAMin: 0, // price check is done separately vs quote below + amountBMin: 0, to: receiver, deadline: block.timestamp }); swapped = liquidity; + uint256 _zen = zen; + uint256 depositToQuote = (depositB * WAD / depositA) * WAD / (quote * WAD / _halfIn); + require(depositToQuote >= _zen && depositToQuote <= (2 * WAD - _zen), + "KilnUniV3SwapUniv2LP/deposit-price-out-of-bounds" + ); + // If not all buy tokens were used, send the remainder to the receiver - if (bought > amountB) { - GemLike(buy).transfer(receiver, bought - amountB); + if (bought > depositB) { + unchecked { GemLike(buy).transfer(receiver, bought - depositB); } } } diff --git a/src/KilnUniV3SwapUniv2LP.t.sol b/src/KilnUniV3SwapUniv2LP.t.sol index a703f7f..0cf5afd 100644 --- a/src/KilnUniV3SwapUniv2LP.t.sol +++ b/src/KilnUniV3SwapUniv2LP.t.sol @@ -76,7 +76,7 @@ contract KilnTest is Test { User user; uint256 halfLot; - uint256 refOneWad; + uint256 refSmall; uint256 refHalfLot; address pairToken; @@ -87,7 +87,8 @@ contract KilnTest is Test { address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address constant MKR = 0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2; - uint256 constant WAD = 1e18; + uint256 constant WAD = 1e18; + uint256 constant SMALL = 1e16; address constant UNIV2ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; address constant UNIV2DAIMKRLP = 0x517F9dD285e75b599234F7221227339478d0FcC8; @@ -125,12 +126,12 @@ contract KilnTest is Test { // console.log("refHalfLot: %s", refHalfLot); // When changing univ2 price we'll use one WAD as reference fire only deposit theres (no price change) - refOneWad = getRefOutAMount(WAD); + refSmall = getRefOutAMount(SMALL); // Bootstrapping - // As there's almost no initial liquidity in v2, need to arb the price then deposit a reasonable amount // As these are small amounts involved the assumption is that it will happen separately from kiln - changeUniv2Price(WAD, refOneWad * 995 / 1000, refOneWad * 1005 / 1000); + changeUniv2Price(SMALL, refSmall * 995 / 1000, refSmall * 1005 / 1000); } function getRefOutAMount(uint256 amountIn) internal view returns (uint256) { @@ -337,27 +338,27 @@ contract KilnTest is Test { Note that `Higher` stands for higher out amount than the reference, while `Lower` stands for lower out amount than the reference. - When Univ3 out amount is higher than the reference a yen of 100% should allow it, and we assume 105% blocks it. - When Univ3 out amount is lower than the reference we assume a yen of 95% should allow it, and 100% blocks it. - When Univ2 out amount is either lower or higher a zen of 95% should allow it, and 100% blocks it. + When Univ3 out amount is higher than the reference a yen of 100% should allow it, and we assume 102% blocks it. + When Univ3 out amount is lower than the reference we assume a yen of 98% should allow it, and 100% blocks it. + When Univ2 out amount is either lower or higher a zen of 98% should allow it, and 100% blocks it. testFire ├── Univ3Higher │ ├── YenAllows (1.00) │ │ ├── Univ2Higher - │ │ │ ├── ZenAllows (0.95) + │ │ │ ├── ZenAllows (0.98) │ │ │ └── ZenBlocks (1.0) │ │ └── Univ2Lower - │ │ ├── ZenAllows (0.95) + │ │ ├── ZenAllows (0.98) │ │ └── ZenBlocks (1.00) - │ └── YenBlocks (1.05) + │ └── YenBlocks (1.02) └── Univ3Lower - ├── YenAllows (0.95) + ├── YenAllows (0.98) │ ├── Univ2Higher - │ │ ├── ZenAllows (0.95) + │ │ ├── ZenAllows (0.98) │ │ └── ZenBlocks (1.00) │ └── Univ2Lower - │ ├── ZenAllows (0.95) + │ ├── ZenAllows (0.98) │ └── ZenBlocks (1.00) └── YenBlocks (1.00) */ @@ -366,7 +367,7 @@ contract KilnTest is Test { changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 100 * WAD / 100); - changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); + changeUniv2Price(SMALL, refSmall, refSmall * 102 / 100); kiln.file("zen", 98 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); @@ -381,14 +382,12 @@ contract KilnTest is Test { changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 100 * WAD / 100); - changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); + changeUniv2Price(SMALL, refSmall, refSmall * 102 / 100); kiln.file("zen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); - // Note that if both uniV2 min amounts don't suffice the revert is "INSUFFICIENT_A_AMOUNT" - - // https://github.com/Uniswap/v2-periphery/blob/master/contracts/UniswapV2Router02.sol#L56 - vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); + vm.expectRevert("KilnUniV3SwapUniv2LP/deposit-price-out-of-bounds"); kiln.fire(); } @@ -396,7 +395,7 @@ contract KilnTest is Test { changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 100 * WAD / 100); - changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); + changeUniv2Price(SMALL, refSmall * 98 / 100, refSmall); kiln.file("zen", 98 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); @@ -411,11 +410,11 @@ contract KilnTest is Test { changeUniv3Price(halfLot, refHalfLot, refHalfLot * 102 / 100); kiln.file("yen", 100 * WAD / 100); - changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); + changeUniv2Price(SMALL, refSmall * 98 / 100, refSmall); kiln.file("zen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); - vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); + vm.expectRevert("KilnUniV3SwapUniv2LP/deposit-price-out-of-bounds"); kiln.fire(); } @@ -433,7 +432,7 @@ contract KilnTest is Test { changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 98 * WAD / 100); - changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); + changeUniv2Price(SMALL, refSmall, refSmall * 102 / 100); kiln.file("zen", 98 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); @@ -449,11 +448,11 @@ contract KilnTest is Test { changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 98 * WAD / 100); - changeUniv2Price(WAD, refOneWad, refOneWad * 102 / 100); + changeUniv2Price(SMALL, refSmall, refSmall * 102 / 100); kiln.file("zen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); - vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); + vm.expectRevert("KilnUniV3SwapUniv2LP/deposit-price-out-of-bounds"); kiln.fire(); } @@ -461,7 +460,7 @@ contract KilnTest is Test { changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 98 * WAD / 100); - changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); + changeUniv2Price(SMALL, refSmall * 98 / 100, refSmall); kiln.file("zen", 98 * WAD / 100); deal(DAI, address(kiln), 50_000 * WAD); @@ -476,11 +475,11 @@ contract KilnTest is Test { changeUniv3Price(halfLot, refHalfLot * 98 / 100, refHalfLot); kiln.file("yen", 98 * WAD / 100); - changeUniv2Price(WAD, refOneWad * 98 / 100, refOneWad); + changeUniv2Price(SMALL, refSmall * 98 / 100, refSmall); kiln.file("zen", 1 * WAD); deal(DAI, address(kiln), 50_000 * WAD); - vm.expectRevert("UniswapV2Router: INSUFFICIENT_A_AMOUNT"); + vm.expectRevert("KilnUniV3SwapUniv2LP/deposit-price-out-of-bounds"); kiln.fire(); } diff --git a/src/quoters/MaxAggregator.t.sol b/src/quoters/MaxAggregator.t.sol index 045d32c..534b490 100644 --- a/src/quoters/MaxAggregator.t.sol +++ b/src/quoters/MaxAggregator.t.sol @@ -19,7 +19,7 @@ pragma solidity ^0.8.14; import "forge-std/Test.sol"; import "src/quoters/MaxAggregator.sol"; -contract AffregatorTest is Test { +contract AggregatorTest is Test { MaxAggregator aggregator; event Rely(address indexed usr);