From 878e12c2bd6df07b678aa0e3b770bf6d5b423a26 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Fri, 17 Mar 2023 14:19:21 -0400 Subject: [PATCH 01/16] add swap pool --- src/pools/D3MSwapPool.sol | 292 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 src/pools/D3MSwapPool.sol diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol new file mode 100644 index 00000000..eed78cdf --- /dev/null +++ b/src/pools/D3MSwapPool.sol @@ -0,0 +1,292 @@ +// 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 "./ID3MPool.sol"; + +interface VatLike { + function hope(address) external; + function nope(address) external; + function move(address, address, uint256) external; + function slip(bytes32, address, int256) external; + function frob(bytes32, address, address, address, int256, int256) external; + function suck(address, address, uint256) external; + function urns(bytes32, address) external view returns (uint256, uint256); + function ilks(bytes32) external view returns (uint256, uint256, uint256, uint256, uint256); +} + +interface DaiJoinLike { + function vat() external view returns (address); + function dai() external view returns (address); + function join(address, uint256) external; + function exit(address, uint256) external; +} + +interface TokenLike { + function decimals() external view returns (uint8); + function approve(address, uint256) external returns (bool); + function transfer(address, uint256) external returns (bool); + function transferFrom(address, address, uint256) external returns (bool); +} + +interface PipLike { + function read() external view returns (bytes32); +} + +interface D3mHubLike { + function vat() external view returns (address); + function end() external view returns (EndLike); +} + +interface EndLike { + function Art(bytes32) external view returns (uint256); +} + +/** + * @title D3M Swap Pool + * @notice Swap one asset for another. Pays market participants to hit desired ratio. + */ +contract D3MSwapPool is ID3MPool { + + // --- Data --- + mapping (address => uint256) public wards; + address public hub; + uint256 public exited; + uint256 public buffer; // Keep a buffer in DAI for liquidity [WAD] + + int256 public tin; // toll in [wad] + int256 public tout; // toll out [wad] + + bytes32 immutable public ilk; + VatLike immutable public vat; + TokenLike immutable public dai; + TokenLike immutable public gem; + PipLike immutable public pip; + + uint256 immutable private GEM_CONVERSION_FACTOR; + + uint256 constant WAD = 10 ** 18; + int256 constant SWAD = 10 ** 18; + uint256 constant RAY = 10 ** 27; + + string constant ARITHMETIC_ERROR = string(abi.encodeWithSignature("Panic(uint256)", 0x11)); + + // --- Events --- + event Rely(address indexed usr); + event Deny(address indexed usr); + event File(bytes32 indexed what, uint256 data); + event File(bytes32 indexed what, int256 data); + event File(bytes32 indexed what, address data); + event SellGem(address indexed owner, uint256 gemsLocked, uint256 daiMinted, int256 fee); + event BuyGem(address indexed owner, uint256 gemsUnlocked, uint256 daiBurned, int256 fee); + + modifier auth { + require(wards[msg.sender] == 1, "D3MSwapPool/not-authorized"); + _; + } + + modifier onlyHub { + require(msg.sender == hub, "D3MSwapPool/only-hub"); + _; + } + + constructor(bytes32 _ilk, address _hub, address _vat, address _dai, address _gem, address _pip) { + wards[msg.sender] = 1; + emit Rely(msg.sender); + + ilk = _ilk; + hub = _hub; + vat = VatLike(_vat); + dai = TokenLike(_dai); + gem = TokenLike(_gem); + pip = TokenLike(_pip); + + GEM_CONVERSION_FACTOR = 10 ** (18 - gem.decimals()); + } + + // --- Math --- + + function _int256(uint256 x) internal pure returns (int256 y) { + require((y = int256(x)) >= 0, ARITHMETIC_ERROR); + } + + function _divup(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = (x + y - 1) / y; + } + + // --- Administration --- + + function rely(address usr) external auth { + wards[usr] = 1; + emit Rely(usr); + } + + function deny(address usr) external auth { + wards[usr] = 0; + emit Deny(usr); + } + + function file(bytes32 what, uint256 data) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + + if (what == "buffer") buffer = data; + else revert("D3MSwapPool/file-unrecognized-param"); + + emit File(what, data); + } + + function file(bytes32 what, int256 data) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + require(-SWAD <= data && data <= SWAD, "D3MSwapPool/out-of-range"); + + if (what == "tin") tin = data; + else if (what == "tout") tout = data; + else revert("D3MSwapPool/file-unrecognized-param"); + + emit File(what, data); + } + + function file(bytes32 what, address data) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + + if (what == "hub") { + vat.nope(hub); + hub = data; + vat.hope(data); + } else revert("D3MSwapPool/file-unrecognized-param"); + + emit File(what, data); + } + + // --- Pool Support --- + + function deposit(uint256 wad) external override onlyHub { + // Nothing to do + } + + function withdraw(uint256 wad) external override onlyHub { + dai.transfer(msg.sender, wad); + } + + function quit(address dst) external override auth { + require(vat.live() == 1, "D3MSwapPool/no-quit-during-shutdown"); + require(gem.transfer(dst, gem.balanceOf(address(this))), "D3MSwapPool/transfer-failed"); + } + + function preDebtChange() external override {} + + function postDebtChange() external override {} + + function assetBalance() public view override returns (uint256) { + return dai.balanceOf(address(this)) + gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; + } + + function maxDeposit() external pure override returns (uint256) { + return type(uint256).max; + } + + function maxWithdraw() external view override returns (uint256) { + return dai.balanceOf(address(this)); + } + + function redeemable() external view override returns (address) { + return address(gem); + } + + function exit(address dst, uint256 wad) external override onlyHub { + uint256 exited_ = exited; + exited = exited_ + wad; + uint256 amt = wad * assetBalance() / ((D3mHubLike(hub).end().Art(ilk) - exited_) * GEM_CONVERSION_FACTOR); + require(gem.transfer(dst, amt), "D3MCompoundV2TypePool/transfer-failed"); + } + + // --- Swaps --- + + function sellGem(address usr, uint256 gemAmt) external { + // TODO + uint256 gemAmt18; + uint256 mintAmount; + { + (address pip,) = spotter.ilks(ilk); + gemAmt18 = gemAmt * to18ConversionFactor; + mintAmount = gemAmt18 * uint256(PipLike(pip).read()) / WAD; // Round down against user + } + + // Transfer in gems and mint dai + (uint256 Art,,, uint256 line,) = vat.ilks(ilk); + require(gem.transferFrom(msg.sender, address(this), gemAmt), "D3MSwapPool/failed-transfer"); + require((Art + mintAmount) * RAY + buff <= line, "D3MSwapPool/buffer-exceeded"); + vat.slip(ilk, address(this), _int256(gemAmt18)); + vat.frob(ilk, address(this), address(this), address(this), int256(gemAmt18), _int256(mintAmount)); + + // Fee calculations + int256 fee = int256(mintAmount) * tin / SWAD; + uint256 daiAmt; + if (fee >= 0) { + // Positive fee - move fee to vow + // NOTE: we exclude the case where ufee > mintAmount in the tin file constraint + uint256 ufee = uint256(fee); + daiAmt = mintAmount - ufee; + vat.move(address(this), vow, ufee * RAY); + } else { + // Negative fee - pay the user extra from the vow + uint256 ufee = uint256(-fee); + daiAmt = mintAmount + ufee; + vat.suck(vow, address(this), ufee * RAY); + } + daiJoin.exit(usr, daiAmt); + + emit SellGem(usr, gemAmt, daiAmt, fee); + } + function buyGem(address usr, uint256 gemAmt) external { + // TODO + uint256 gemAmt18; + uint256 burnAmount; + { + (address pip,) = spotter.ilks(ilk); + gemAmt18 = gemAmt * to18ConversionFactor; + burnAmount = _divup(gemAmt18 * uint256(PipLike(pip).read()), WAD); // Round up against user + } + + // Fee calculations + int256 fee = _int256(burnAmount) * tout / SWAD; + uint256 daiAmt; + if (fee >= 0) { + // Positive fee - move fee to vow below after daiAmt comes in + daiAmt = burnAmount + uint256(fee); + } else { + // Negative fee - pay the user extra from the vow + // NOTE: we exclude the case where ufee > burnAmount in the tout file constraint + uint256 ufee = uint256(-fee); + daiAmt = burnAmount - ufee; + vat.suck(vow, address(this), ufee * RAY); + } + + // Transfer in dai, repay loan and transfer out gems + require(dai.transferFrom(msg.sender, address(this), daiAmt), "D3MSwapPool/failed-transfer"); + daiJoin.join(address(this), daiAmt); + vat.frob(ilk, address(this), address(this), address(this), -_int256(gemAmt18), -int256(burnAmount)); + vat.slip(ilk, address(this), -int256(gemAmt18)); + require(gem.transfer(usr, gemAmt), "D3MSwapPool/failed-transfer"); + if (fee >= 0) { + vat.move(address(this), vow, uint256(fee) * RAY); + } + + emit BuyGem(usr, gemAmt, daiAmt, fee); + } + +} From 21911d32e11d0cafb4949553f2f0318a19304ca3 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 21 Mar 2023 12:07:19 -0400 Subject: [PATCH 02/16] finish swap pool and tests --- src/pools/D3MSwapPool.sol | 204 ++++++++++----------- src/tests/pools/D3MSwapPool.t.sol | 288 ++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 109 deletions(-) create mode 100644 src/tests/pools/D3MSwapPool.t.sol diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index eed78cdf..f986f71c 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -19,6 +19,7 @@ pragma solidity ^0.8.14; import "./ID3MPool.sol"; interface VatLike { + function live() external view returns (uint256); function hope(address) external; function nope(address) external; function move(address, address, uint256) external; @@ -38,7 +39,7 @@ interface DaiJoinLike { interface TokenLike { function decimals() external view returns (uint8); - function approve(address, uint256) external returns (bool); + function balanceOf(address) external view returns (uint256); function transfer(address, uint256) external returns (bool); function transferFrom(address, address, uint256) external returns (bool); } @@ -47,8 +48,8 @@ interface PipLike { function read() external view returns (bytes32); } -interface D3mHubLike { - function vat() external view returns (address); +interface HubLike { + function vat() external view returns (VatLike); function end() external view returns (EndLike); } @@ -58,41 +59,38 @@ interface EndLike { /** * @title D3M Swap Pool - * @notice Swap one asset for another. Pays market participants to hit desired ratio. + * @notice Swap an asset for DAI. Fees vary based on whether the pool is above or below the buffer. */ contract D3MSwapPool is ID3MPool { // --- Data --- mapping (address => uint256) public wards; - address public hub; - uint256 public exited; - uint256 public buffer; // Keep a buffer in DAI for liquidity [WAD] - int256 public tin; // toll in [wad] - int256 public tout; // toll out [wad] + HubLike public hub; + PipLike public pip; + uint256 public buffer; // Keep a buffer in DAI for liquidity [WAD] + uint256 public tin1; // toll in under the buffer [wad] + uint256 public tin2; // toll in over the buffer [wad] + uint256 public tout1; // toll out over the buffer [wad] + uint256 public tout2; // toll out under the buffer [wad] + uint256 public exited; bytes32 immutable public ilk; VatLike immutable public vat; TokenLike immutable public dai; TokenLike immutable public gem; - PipLike immutable public pip; uint256 immutable private GEM_CONVERSION_FACTOR; uint256 constant WAD = 10 ** 18; - int256 constant SWAD = 10 ** 18; - uint256 constant RAY = 10 ** 27; - - string constant ARITHMETIC_ERROR = string(abi.encodeWithSignature("Panic(uint256)", 0x11)); // --- Events --- event Rely(address indexed usr); event Deny(address indexed usr); event File(bytes32 indexed what, uint256 data); - event File(bytes32 indexed what, int256 data); event File(bytes32 indexed what, address data); - event SellGem(address indexed owner, uint256 gemsLocked, uint256 daiMinted, int256 fee); - event BuyGem(address indexed owner, uint256 gemsUnlocked, uint256 daiBurned, int256 fee); + event SellGem(address indexed owner, uint256 gems, uint256 dai); + event BuyGem(address indexed owner, uint256 gems, uint256 dai); modifier auth { require(wards[msg.sender] == 1, "D3MSwapPool/not-authorized"); @@ -100,34 +98,24 @@ contract D3MSwapPool is ID3MPool { } modifier onlyHub { - require(msg.sender == hub, "D3MSwapPool/only-hub"); + require(msg.sender == address(hub), "D3MSwapPool/only-hub"); _; } - constructor(bytes32 _ilk, address _hub, address _vat, address _dai, address _gem, address _pip) { + constructor(bytes32 _ilk, address _hub, address _dai, address _gem) { wards[msg.sender] = 1; emit Rely(msg.sender); ilk = _ilk; - hub = _hub; - vat = VatLike(_vat); + hub = HubLike(_hub); + vat = HubLike(hub).vat(); dai = TokenLike(_dai); gem = TokenLike(_gem); - pip = TokenLike(_pip); + vat.hope(_hub); GEM_CONVERSION_FACTOR = 10 ** (18 - gem.decimals()); } - // --- Math --- - - function _int256(uint256 x) internal pure returns (int256 y) { - require((y = int256(x)) >= 0, ARITHMETIC_ERROR); - } - - function _divup(uint256 x, uint256 y) internal pure returns (uint256 z) { - z = (x + y - 1) / y; - } - // --- Administration --- function rely(address usr) external auth { @@ -144,17 +132,10 @@ contract D3MSwapPool is ID3MPool { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); if (what == "buffer") buffer = data; - else revert("D3MSwapPool/file-unrecognized-param"); - - emit File(what, data); - } - - function file(bytes32 what, int256 data) external auth { - require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); - require(-SWAD <= data && data <= SWAD, "D3MSwapPool/out-of-range"); - - if (what == "tin") tin = data; - else if (what == "tout") tout = data; + else if (what == "tin1") tin1 = data; + else if (what == "tin2") tin2 = data; + else if (what == "tout1") tout1 = data; + else if (what == "tout2") tout2 = data; else revert("D3MSwapPool/file-unrecognized-param"); emit File(what, data); @@ -164,9 +145,11 @@ contract D3MSwapPool is ID3MPool { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); if (what == "hub") { - vat.nope(hub); - hub = data; + vat.nope(address(hub)); + hub = HubLike(data); vat.hope(data); + } else if (what == "pip") { + pip = PipLike(data); } else revert("D3MSwapPool/file-unrecognized-param"); emit File(what, data); @@ -210,83 +193,86 @@ contract D3MSwapPool is ID3MPool { function exit(address dst, uint256 wad) external override onlyHub { uint256 exited_ = exited; exited = exited_ + wad; - uint256 amt = wad * assetBalance() / ((D3mHubLike(hub).end().Art(ilk) - exited_) * GEM_CONVERSION_FACTOR); - require(gem.transfer(dst, amt), "D3MCompoundV2TypePool/transfer-failed"); + uint256 amt = wad * gem.balanceOf(address(this)) / (hub.end().Art(ilk) - exited_); + require(gem.transfer(dst, amt), "D3MSwapPool/transfer-failed"); } // --- Swaps --- - function sellGem(address usr, uint256 gemAmt) external { - // TODO - uint256 gemAmt18; - uint256 mintAmount; - { - (address pip,) = spotter.ilks(ilk); - gemAmt18 = gemAmt * to18ConversionFactor; - mintAmount = gemAmt18 * uint256(PipLike(pip).read()) / WAD; // Round down against user - } - - // Transfer in gems and mint dai - (uint256 Art,,, uint256 line,) = vat.ilks(ilk); - require(gem.transferFrom(msg.sender, address(this), gemAmt), "D3MSwapPool/failed-transfer"); - require((Art + mintAmount) * RAY + buff <= line, "D3MSwapPool/buffer-exceeded"); - vat.slip(ilk, address(this), _int256(gemAmt18)); - vat.frob(ilk, address(this), address(this), address(this), int256(gemAmt18), _int256(mintAmount)); - - // Fee calculations - int256 fee = int256(mintAmount) * tin / SWAD; - uint256 daiAmt; - if (fee >= 0) { - // Positive fee - move fee to vow - // NOTE: we exclude the case where ufee > mintAmount in the tin file constraint - uint256 ufee = uint256(fee); - daiAmt = mintAmount - ufee; - vat.move(address(this), vow, ufee * RAY); + function previewSellGem(uint256 gemAmt) public view returns (uint256 daiAmt) { + uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; + uint256 daiBalance = dai.balanceOf(address(this)); + uint256 _buffer = buffer; + if (daiBalance <= _buffer) { + // We are above the buffer so apply tin2 + daiAmt = gemValue * tin2 / WAD; } else { - // Negative fee - pay the user extra from the vow - uint256 ufee = uint256(-fee); - daiAmt = mintAmount + ufee; - vat.suck(vow, address(this), ufee * RAY); + uint256 daiAvailableAtTin1; + unchecked { + daiAvailableAtTin1 = daiBalance - _buffer; + } + + // We are below the buffer so could be a mix of tin1 and tin2 + uint256 daiAmtTin1 = gemValue * tin1 / WAD; + if (daiAmtTin1 <= daiAvailableAtTin1) { + // We are entirely in the tin1 region + daiAmt = daiAmtTin1; + } else { + // We are a mix between tin1 and tin2 + uint256 daiRemainder; + unchecked { + daiRemainder = daiAmtTin1 - daiAvailableAtTin1; + } + daiAmt = daiAvailableAtTin1 + (daiRemainder * WAD / tin1) * tin2 / WAD; + } } - daiJoin.exit(usr, daiAmt); - - emit SellGem(usr, gemAmt, daiAmt, fee); } - function buyGem(address usr, uint256 gemAmt) external { - // TODO - uint256 gemAmt18; - uint256 burnAmount; - { - (address pip,) = spotter.ilks(ilk); - gemAmt18 = gemAmt * to18ConversionFactor; - burnAmount = _divup(gemAmt18 * uint256(PipLike(pip).read()), WAD); // Round up against user - } - // Fee calculations - int256 fee = _int256(burnAmount) * tout / SWAD; - uint256 daiAmt; - if (fee >= 0) { - // Positive fee - move fee to vow below after daiAmt comes in - daiAmt = burnAmount + uint256(fee); + function previewBuyGem(uint256 daiAmt) public view returns (uint256 gemAmt) { + uint256 gemValue; + uint256 daiBalance = dai.balanceOf(address(this)); + uint256 _buffer = buffer; + if (daiBalance >= _buffer) { + // We are below the buffer so apply tout2 + gemValue = daiAmt * tout2 / WAD; } else { - // Negative fee - pay the user extra from the vow - // NOTE: we exclude the case where ufee > burnAmount in the tout file constraint - uint256 ufee = uint256(-fee); - daiAmt = burnAmount - ufee; - vat.suck(vow, address(this), ufee * RAY); + uint256 daiAvailableAtTout1; + unchecked { + daiAvailableAtTout1 = _buffer - daiBalance; + } + + // We are above the buffer so could be a mix of tout1 and tout1 + if (daiAmt <= daiAvailableAtTout1) { + // We are entirely in the tout1 region + gemValue = daiAmt * tout1 / WAD; + } else { + // We are a mix between tout1 and tout1 + uint256 daiRemainder; + unchecked { + daiRemainder = daiAmt - daiAvailableAtTout1; + } + gemValue = daiAvailableAtTout1 * tout1 / WAD + daiRemainder * tout2 / WAD; + } } + gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * uint256(pip.read())); + } + + function sellGem(address usr, uint256 gemAmt, uint256 minDaiAmt) external returns (uint256 daiAmt) { + daiAmt = previewSellGem(gemAmt); + require(daiAmt >= minDaiAmt, "D3MSwapPool/too-little-dai"); + require(gem.transferFrom(msg.sender, address(this), gemAmt), "D3MSwapPool/failed-transfer"); + dai.transfer(usr, daiAmt); - // Transfer in dai, repay loan and transfer out gems - require(dai.transferFrom(msg.sender, address(this), daiAmt), "D3MSwapPool/failed-transfer"); - daiJoin.join(address(this), daiAmt); - vat.frob(ilk, address(this), address(this), address(this), -_int256(gemAmt18), -int256(burnAmount)); - vat.slip(ilk, address(this), -int256(gemAmt18)); + emit SellGem(usr, gemAmt, daiAmt); + } + + function buyGem(address usr, uint256 daiAmt, uint256 minGemAmt) external returns (uint256 gemAmt) { + gemAmt = previewBuyGem(daiAmt); + require(gemAmt >= minGemAmt, "D3MSwapPool/too-little-gems"); + dai.transferFrom(msg.sender, address(this), daiAmt); require(gem.transfer(usr, gemAmt), "D3MSwapPool/failed-transfer"); - if (fee >= 0) { - vat.move(address(this), vow, uint256(fee) * RAY); - } - emit BuyGem(usr, gemAmt, daiAmt, fee); + emit BuyGem(usr, gemAmt, daiAmt); } } diff --git a/src/tests/pools/D3MSwapPool.t.sol b/src/tests/pools/D3MSwapPool.t.sol new file mode 100644 index 00000000..3c412495 --- /dev/null +++ b/src/tests/pools/D3MSwapPool.t.sol @@ -0,0 +1,288 @@ +// 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 { D3MPoolBaseTest, FakeHub, FakeVat, FakeEnd, DaiLike } from "./D3MPoolBase.t.sol"; +import { D3MTestGem } from "../stubs/D3MTestGem.sol"; + +import { D3MSwapPool } from "../../pools/D3MSwapPool.sol"; + +contract PipMock { + uint256 public val; + function read() external view returns (bytes32) { + return bytes32(val); + } + function void() external { + val = 0; + } + function poke(uint256 wut) external { + val = wut; + } +} + +contract D3MSwapPoolTest is D3MPoolBaseTest { + + bytes32 constant ILK = "TEST-ILK"; + + D3MSwapPool swapPool; + D3MTestGem gem; + FakeEnd end; + PipMock pip; + + event SellGem(address indexed owner, uint256 gems, uint256 dai); + event BuyGem(address indexed owner, uint256 gems, uint256 dai); + + function setUp() public override { + contractName = "D3MSwapPool"; + + dai = DaiLike(address(new D3MTestGem(18))); + gem = new D3MTestGem(6); + + vat = address(new FakeVat()); + + hub = address(new FakeHub(vat)); + end = FakeHub(hub).end(); + pip = new PipMock(); + pip.poke(2e18); // Gem is worth $2 / unit + + d3mTestPool = address(swapPool = new D3MSwapPool(ILK, hub, address(dai), address(gem))); + swapPool.file("pip", address(pip)); + + swapPool.file("buffer", 10 ether); // 10 DAI buffer to switch between tin/tout1 and tin/tout2 + swapPool.file("tin1", 10005 * WAD / BPS); // 5 bps negative wind fee (pay people to wind) + swapPool.file("tin2", 9990 * WAD / BPS); // 10 bps fee after the buffer is reached + swapPool.file("tout1", 10015 * WAD / BPS); // 15 bps negative unwind fee (pay people to unwind) + swapPool.file("tout2", 9980 * WAD / BPS); // 20 bps fee after the buffer is reached + + gem.approve(d3mTestPool, type(uint256).max); + dai.approve(d3mTestPool, type(uint256).max); + } + + function test_constructor() public { + assertEq(address(swapPool.hub()), hub); + assertEq(swapPool.ilk(), ILK); + assertEq(address(swapPool.vat()), vat); + assertEq(address(swapPool.dai()), address(dai)); + assertEq(address(swapPool.gem()), address(gem)); + } + + function test_file() public { + checkFileUint(d3mTestPool, contractName, ["buffer", "tin1", "tin2", "tout1", "tout2"]); + checkFileAddress(d3mTestPool, contractName, ["hub", "pip"]); + } + + function test_withdraw() public { + uint256 startingBal = dai.balanceOf(address(this)); + dai.transfer(d3mTestPool, 100 ether); + assertEq(dai.balanceOf(d3mTestPool), 100 ether); + assertEq(dai.balanceOf(address(this)), startingBal - 100 ether); + assertEq(dai.balanceOf(hub), 0); + vm.prank(hub); swapPool.withdraw(50 ether); + assertEq(dai.balanceOf(d3mTestPool), 50 ether); + assertEq(dai.balanceOf(address(this)), startingBal - 100 ether); + assertEq(dai.balanceOf(hub), 50 ether); + } + + function test_redeemable_returns_gem() public { + assertEq(swapPool.redeemable(), address(gem)); + } + + function test_exit_gem() public { + uint256 tokens = gem.balanceOf(address(this)); + gem.transfer(d3mTestPool, tokens); + assertEq(gem.balanceOf(address(this)), 0); + assertEq(gem.balanceOf(d3mTestPool), tokens); + + end.setArt(tokens); + vm.prank(hub); swapPool.exit(address(this), tokens); + + assertEq(gem.balanceOf(address(this)), tokens); + assertEq(gem.balanceOf(d3mTestPool), 0); + } + + function test_quit_moves_balance() public { + uint256 tokens = gem.balanceOf(address(this)); + gem.transfer(d3mTestPool, tokens); + assertEq(gem.balanceOf(address(this)), 0); + assertEq(gem.balanceOf(d3mTestPool), tokens); + + swapPool.quit(address(this)); + + assertEq(gem.balanceOf(address(this)), tokens); + assertEq(gem.balanceOf(d3mTestPool), 0); + } + + function test_assetBalance() public { + assertEq(swapPool.assetBalance(), 0); + + gem.transfer(d3mTestPool, 10 * 1e6); + dai.transfer(d3mTestPool, 30 ether); + + assertEq(swapPool.assetBalance(), 50 ether); // 10 tokens @ $2 / unit + 30 dai + } + + function test_maxDeposit() public { + assertEq(swapPool.maxDeposit(), type(uint256).max); + } + + function test_maxWithdraw() public { + dai.transfer(d3mTestPool, 100 ether); + + assertEq(swapPool.maxWithdraw(), 100 ether); + } + + function test_previewSellGem_under_buffer() public { + dai.transfer(d3mTestPool, 100 ether); + + // 10 tokens @ $2 / unit + 5bps payment = 20.01 + assertEq(swapPool.previewSellGem(10 * 1e6), 20.010 ether); + } + + function test_previewSellGem_over_buffer() public { + swapPool.file("buffer", 100 ether); + dai.transfer(d3mTestPool, 100 ether); + + // 10 tokens @ $2 / unit + 10bps fee = 19.98 + assertEq(swapPool.previewSellGem(10 * 1e6), 19.980 ether); + } + + function test_previewSellGem_mixed_fees() public { + dai.transfer(d3mTestPool, 100 ether); + + // ~45 tokens will earn the 5bps fee, remainding ~55 pays the 10bps fee + assertEq(swapPool.previewSellGem(100 * 1e6), 199934932533733133433); + } + + function test_previewSellGem_mixed_fees_exact_cancel() public { + dai.transfer(d3mTestPool, 100 ether); + swapPool.file("tin2", WAD*WAD / swapPool.tin1()); + + // ~45 tokens will earn the 5bps fee, remainding ~45 pays the 5bps fee + // Allow for a 1bps error due to rounding + assertApproxEqRel(swapPool.previewSellGem(90 * 1e6), 180 ether, WAD / 10000); + } + + function test_previewSellGem_buffer_zero_dai_zero() public { + swapPool.file("buffer", 0); + + // 10 tokens @ $2 / unit + 10bps fee = 19.98 + assertEq(swapPool.previewSellGem(10 * 1e6), 19.980 ether); + } + + function test_previewSellGem_buffer_zero() public { + swapPool.file("buffer", 0); + dai.transfer(d3mTestPool, 10 ether); + + assertEq(swapPool.previewSellGem(10 * 1e6), 19994992503748125936); + } + + function test_previewSellGem_dai_zero() public { + assertEq(swapPool.previewSellGem(10 * 1e6), 19.980 ether); + } + + function test_previewBuyGem_over_buffer() public { + dai.transfer(d3mTestPool, 5 ether); + + // 4 DAI + 15bps payment = 2.003 tokens + assertEq(swapPool.previewBuyGem(4 ether), 2.003 * 1e6); + } + + function test_previewBuyGem_under_buffer() public { + dai.transfer(d3mTestPool, 100 ether); + + // 20 DAI + 20bps fee = 9.96 tokens + assertEq(swapPool.previewBuyGem(20 ether), 9.98 * 1e6); + } + + function test_previewBuyGem_mixed_fees() public { + dai.transfer(d3mTestPool, 5 ether); + + // 5 of the DAI gets paid the 15bps fee, the other 5 pays the 20bps fee + // Result is slightly less than 5 tokens + assertEq(swapPool.previewBuyGem(10 ether), 4998750); + } + + function test_previewBuyGem_mixed_fees_exact_cancel() public { + dai.transfer(d3mTestPool, 5 ether); + swapPool.file("tout2", WAD*WAD / swapPool.tout1()); + + // 10 DAI unwind should almost exactly cancel out + // Allow for a 1bps error due to rounding + assertApproxEqRel(swapPool.previewBuyGem(10 ether), 5 * 1e6, WAD / 10000); + } + + function test_previewBuyGem_buffer_zero_dai_zero() public { + swapPool.file("buffer", 0); + + assertEq(swapPool.previewBuyGem(20 ether), 9.98 * 1e6); + } + + function test_previewBuyGem_buffer_zero() public { + swapPool.file("buffer", 0); + dai.transfer(d3mTestPool, 10 ether); + + assertEq(swapPool.previewBuyGem(20 ether), 9.98 * 1e6); + } + + function test_previewBuyGem_dai_zero() public { + assertEq(swapPool.previewBuyGem(4 ether), 2.003 * 1e6); + } + + function test_sellGem() public { + dai.transfer(d3mTestPool, 100 ether); + + uint256 gemBal = gem.balanceOf(address(this)); + assertEq(dai.balanceOf(TEST_ADDRESS), 0); + + vm.expectEmit(true, true, true, true); + emit SellGem(TEST_ADDRESS, 10 * 1e6, 20.01 ether); + swapPool.sellGem(TEST_ADDRESS, 10 * 1e6, swapPool.previewSellGem(10 * 1e6)); + + assertEq(gem.balanceOf(address(this)), gemBal - 10 * 1e6); + assertEq(dai.balanceOf(TEST_ADDRESS), 20.01 ether); + } + + function test_sellGem_minDaiAmt_too_high() public { + dai.transfer(d3mTestPool, 100 ether); + + uint256 amt = swapPool.previewSellGem(10 * 1e6); + vm.expectRevert("D3MSwapPool/too-little-dai"); + swapPool.sellGem(TEST_ADDRESS, 10 * 1e6, amt + 1); + } + + function test_buyGem() public { + gem.transfer(d3mTestPool, 100 * 1e6); + + uint256 daiBal = dai.balanceOf(address(this)); + assertEq(gem.balanceOf(TEST_ADDRESS), 0); + + vm.expectEmit(true, true, true, true); + emit BuyGem(TEST_ADDRESS, 5.0075 * 1e6, 10 ether); + swapPool.buyGem(TEST_ADDRESS, 10 ether, swapPool.previewBuyGem(10 ether)); + + assertEq(gem.balanceOf(TEST_ADDRESS), 5.0075 * 1e6); + assertEq(dai.balanceOf(address(this)), daiBal - 10 ether); + } + + function test_buyGem_minGemAmt_too_high() public { + gem.transfer(d3mTestPool, 100 * 1e6); + + uint256 amt = swapPool.previewBuyGem(10 ether); + vm.expectRevert("D3MSwapPool/too-little-gems"); + swapPool.buyGem(TEST_ADDRESS, 10 ether, amt + 1); + } +} From 6c8aa5fdf09cabb6405fe4777b278b06f2125a42 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 21 Mar 2023 12:10:14 -0400 Subject: [PATCH 03/16] remove unused interface --- src/pools/D3MSwapPool.sol | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index f986f71c..3b0e5a5a 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -22,19 +22,6 @@ interface VatLike { function live() external view returns (uint256); function hope(address) external; function nope(address) external; - function move(address, address, uint256) external; - function slip(bytes32, address, int256) external; - function frob(bytes32, address, address, address, int256, int256) external; - function suck(address, address, uint256) external; - function urns(bytes32, address) external view returns (uint256, uint256); - function ilks(bytes32) external view returns (uint256, uint256, uint256, uint256, uint256); -} - -interface DaiJoinLike { - function vat() external view returns (address); - function dai() external view returns (address); - function join(address, uint256) external; - function exit(address, uint256) external; } interface TokenLike { From 67bd496253270882bb86706d208af5637cb1297e Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Wed, 22 Mar 2023 07:02:32 -0400 Subject: [PATCH 04/16] reverse the meaning of tout1 and tout2 (makes it clearer) --- src/pools/D3MSwapPool.sol | 28 ++++++++++++++-------------- src/tests/pools/D3MSwapPool.t.sol | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index 3b0e5a5a..b4fbf103 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -55,11 +55,11 @@ contract D3MSwapPool is ID3MPool { HubLike public hub; PipLike public pip; - uint256 public buffer; // Keep a buffer in DAI for liquidity [WAD] + uint256 public buffer; // Keep a buffer in DAI for liquidity [wad] uint256 public tin1; // toll in under the buffer [wad] + uint256 public tout1; // toll out under the buffer [wad] uint256 public tin2; // toll in over the buffer [wad] - uint256 public tout1; // toll out over the buffer [wad] - uint256 public tout2; // toll out under the buffer [wad] + uint256 public tout2; // toll out over the buffer [wad] uint256 public exited; bytes32 immutable public ilk; @@ -120,8 +120,8 @@ contract D3MSwapPool is ID3MPool { if (what == "buffer") buffer = data; else if (what == "tin1") tin1 = data; - else if (what == "tin2") tin2 = data; else if (what == "tout1") tout1 = data; + else if (what == "tin2") tin2 = data; else if (what == "tout2") tout2 = data; else revert("D3MSwapPool/file-unrecognized-param"); @@ -220,25 +220,25 @@ contract D3MSwapPool is ID3MPool { uint256 daiBalance = dai.balanceOf(address(this)); uint256 _buffer = buffer; if (daiBalance >= _buffer) { - // We are below the buffer so apply tout2 - gemValue = daiAmt * tout2 / WAD; + // We are below the buffer so apply tout1 + gemValue = daiAmt * tout1 / WAD; } else { - uint256 daiAvailableAtTout1; + uint256 daiAvailableAtTout2; unchecked { - daiAvailableAtTout1 = _buffer - daiBalance; + daiAvailableAtTout2 = _buffer - daiBalance; } - // We are above the buffer so could be a mix of tout1 and tout1 - if (daiAmt <= daiAvailableAtTout1) { + // We are above the buffer so could be a mix of tout1 and tout2 + if (daiAmt <= daiAvailableAtTout2) { // We are entirely in the tout1 region - gemValue = daiAmt * tout1 / WAD; + gemValue = daiAmt * tout2 / WAD; } else { - // We are a mix between tout1 and tout1 + // We are a mix between tout1 and tout2 uint256 daiRemainder; unchecked { - daiRemainder = daiAmt - daiAvailableAtTout1; + daiRemainder = daiAmt - daiAvailableAtTout2; } - gemValue = daiAvailableAtTout1 * tout1 / WAD + daiRemainder * tout2 / WAD; + gemValue = daiAvailableAtTout2 * tout2 / WAD + daiRemainder * tout1 / WAD; } } gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * uint256(pip.read())); diff --git a/src/tests/pools/D3MSwapPool.t.sol b/src/tests/pools/D3MSwapPool.t.sol index 3c412495..f23df175 100644 --- a/src/tests/pools/D3MSwapPool.t.sol +++ b/src/tests/pools/D3MSwapPool.t.sol @@ -64,9 +64,9 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { swapPool.file("buffer", 10 ether); // 10 DAI buffer to switch between tin/tout1 and tin/tout2 swapPool.file("tin1", 10005 * WAD / BPS); // 5 bps negative wind fee (pay people to wind) + swapPool.file("tout1", 9980 * WAD / BPS); // 20 bps unwind fee swapPool.file("tin2", 9990 * WAD / BPS); // 10 bps fee after the buffer is reached - swapPool.file("tout1", 10015 * WAD / BPS); // 15 bps negative unwind fee (pay people to unwind) - swapPool.file("tout2", 9980 * WAD / BPS); // 20 bps fee after the buffer is reached + swapPool.file("tout2", 10015 * WAD / BPS); // 15 bps negative fee (pay people to unwind) gem.approve(d3mTestPool, type(uint256).max); dai.approve(d3mTestPool, type(uint256).max); From 2f6fe850541c848cfddead72e75e5a981c08e14b Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Wed, 22 Mar 2023 07:51:00 -0400 Subject: [PATCH 05/16] file should set tin/tout pairs in combination to check that they do not allow arbitrage opp (which it turns out my example did by mistake :) ) --- src/pools/D3MSwapPool.sol | 21 +++++++++-- src/tests/pools/D3MSwapPool.t.sol | 62 +++++++++++++++++++++++-------- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index b4fbf103..0fe6ddcc 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -75,6 +75,7 @@ contract D3MSwapPool is ID3MPool { event Rely(address indexed usr); event Deny(address indexed usr); event File(bytes32 indexed what, uint256 data); + event File(bytes32 indexed what, uint256 tin, uint256 tout); event File(bytes32 indexed what, address data); event SellGem(address indexed owner, uint256 gems, uint256 dai); event BuyGem(address indexed owner, uint256 gems, uint256 dai); @@ -119,15 +120,27 @@ contract D3MSwapPool is ID3MPool { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); if (what == "buffer") buffer = data; - else if (what == "tin1") tin1 = data; - else if (what == "tout1") tout1 = data; - else if (what == "tin2") tin2 = data; - else if (what == "tout2") tout2 = data; else revert("D3MSwapPool/file-unrecognized-param"); emit File(what, data); } + function file(bytes32 what, uint256 tin, uint256 tout) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + // We need to restrict tin/tout combinations to be less than 100% to avoid arbitrage opportunities. + require(tin * tout <= WAD * WAD, "D3MSwapPool/invalid-fees"); + + if (what == "fees1") { + tin1 = tin; + tout1 = tout; + } else if (what == "fees2") { + tin2 = tin; + tout2 = tout; + } else revert("D3MSwapPool/file-unrecognized-param"); + + emit File(what, tin, tout); + } + function file(bytes32 what, address data) external auth { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); diff --git a/src/tests/pools/D3MSwapPool.t.sol b/src/tests/pools/D3MSwapPool.t.sol index f23df175..1edc5188 100644 --- a/src/tests/pools/D3MSwapPool.t.sol +++ b/src/tests/pools/D3MSwapPool.t.sol @@ -43,6 +43,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { FakeEnd end; PipMock pip; + event File(bytes32 indexed what, uint256 tin, uint256 tout); event SellGem(address indexed owner, uint256 gems, uint256 dai); event BuyGem(address indexed owner, uint256 gems, uint256 dai); @@ -63,11 +64,10 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { swapPool.file("pip", address(pip)); swapPool.file("buffer", 10 ether); // 10 DAI buffer to switch between tin/tout1 and tin/tout2 - swapPool.file("tin1", 10005 * WAD / BPS); // 5 bps negative wind fee (pay people to wind) - swapPool.file("tout1", 9980 * WAD / BPS); // 20 bps unwind fee - swapPool.file("tin2", 9990 * WAD / BPS); // 10 bps fee after the buffer is reached - swapPool.file("tout2", 10015 * WAD / BPS); // 15 bps negative fee (pay people to unwind) - + // 5 bps negative wind fee (pay people to wind), 20 bps unwind fee + swapPool.file("fees1", 10005 * WAD / BPS, 9980 * WAD / BPS); + // 10 bps fee after the buffer is reached, 8 bps negative fee (pay people to unwind) + swapPool.file("fees2", 9990 * WAD / BPS, 10008 * WAD / BPS); gem.approve(d3mTestPool, type(uint256).max); dai.approve(d3mTestPool, type(uint256).max); } @@ -81,10 +81,42 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { } function test_file() public { - checkFileUint(d3mTestPool, contractName, ["buffer", "tin1", "tin2", "tout1", "tout2"]); + checkFileUint(d3mTestPool, contractName, ["buffer"]); checkFileAddress(d3mTestPool, contractName, ["hub", "pip"]); } + function test_file_fees() public { + vm.expectRevert(abi.encodePacked(contractName, "/file-unrecognized-param")); + swapPool.file("an invalid value", 1, 2); + + vm.expectEmit(true, true, true, true); + emit File("fees1", 1, 2); + swapPool.file("fees1", 1, 2); + + assertEq(swapPool.tin1(), 1); + assertEq(swapPool.tout1(), 2); + + vm.expectEmit(true, true, true, true); + emit File("fees2", 3, 4); + swapPool.file("fees2", 3, 4); + + assertEq(swapPool.tin2(), 3); + assertEq(swapPool.tout2(), 4); + + FakeVat(vat).cage(); + vm.expectRevert(abi.encodePacked(contractName, "/no-file-during-shutdown")); + swapPool.file("some value", 1, 2); + + swapPool.deny(address(this)); + vm.expectRevert(abi.encodePacked(contractName, "/not-authorized")); + swapPool.file("some value", 1, 2); + } + + function test_file_invalid_fees() public { + vm.expectRevert(abi.encodePacked(contractName, "/invalid-fees")); + swapPool.file("fees1", WAD + 1, WAD); + } + function test_withdraw() public { uint256 startingBal = dai.balanceOf(address(this)); dai.transfer(d3mTestPool, 100 ether); @@ -169,7 +201,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { function test_previewSellGem_mixed_fees_exact_cancel() public { dai.transfer(d3mTestPool, 100 ether); - swapPool.file("tin2", WAD*WAD / swapPool.tin1()); + swapPool.file("fees2", WAD*WAD / swapPool.tin1(), swapPool.tin1()); // ~45 tokens will earn the 5bps fee, remainding ~45 pays the 5bps fee // Allow for a 1bps error due to rounding @@ -197,8 +229,8 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { function test_previewBuyGem_over_buffer() public { dai.transfer(d3mTestPool, 5 ether); - // 4 DAI + 15bps payment = 2.003 tokens - assertEq(swapPool.previewBuyGem(4 ether), 2.003 * 1e6); + // 4 DAI + 8bps payment = 2.003 tokens + assertEq(swapPool.previewBuyGem(4 ether), 2.0016 * 1e6); } function test_previewBuyGem_under_buffer() public { @@ -211,14 +243,14 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { function test_previewBuyGem_mixed_fees() public { dai.transfer(d3mTestPool, 5 ether); - // 5 of the DAI gets paid the 15bps fee, the other 5 pays the 20bps fee + // 5 of the DAI gets paid the 8bps fee, the other 5 pays the 20bps fee // Result is slightly less than 5 tokens - assertEq(swapPool.previewBuyGem(10 ether), 4998750); + assertEq(swapPool.previewBuyGem(10 ether), 4997000); } function test_previewBuyGem_mixed_fees_exact_cancel() public { dai.transfer(d3mTestPool, 5 ether); - swapPool.file("tout2", WAD*WAD / swapPool.tout1()); + swapPool.file("fees2", swapPool.tout1(), WAD*WAD / swapPool.tout1()); // 10 DAI unwind should almost exactly cancel out // Allow for a 1bps error due to rounding @@ -239,7 +271,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { } function test_previewBuyGem_dai_zero() public { - assertEq(swapPool.previewBuyGem(4 ether), 2.003 * 1e6); + assertEq(swapPool.previewBuyGem(4 ether), 2.0016 * 1e6); } function test_sellGem() public { @@ -271,10 +303,10 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { assertEq(gem.balanceOf(TEST_ADDRESS), 0); vm.expectEmit(true, true, true, true); - emit BuyGem(TEST_ADDRESS, 5.0075 * 1e6, 10 ether); + emit BuyGem(TEST_ADDRESS, 5.004 * 1e6, 10 ether); swapPool.buyGem(TEST_ADDRESS, 10 ether, swapPool.previewBuyGem(10 ether)); - assertEq(gem.balanceOf(TEST_ADDRESS), 5.0075 * 1e6); + assertEq(gem.balanceOf(TEST_ADDRESS), 5.004 * 1e6); assertEq(dai.balanceOf(address(this)), daiBal - 10 ether); } From 86fb1efc56c32fcce88f75ace295c3e77ca03bb0 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Wed, 22 Mar 2023 08:17:53 -0400 Subject: [PATCH 06/16] switch fees to BPS and combine into a single slot for better gas efficiency --- src/pools/D3MSwapPool.sol | 92 +++++++++++++++++++++---------- src/tests/pools/D3MSwapPool.t.sol | 35 +++++++++--- 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index 0fe6ddcc..48bc3862 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -50,16 +50,20 @@ interface EndLike { */ contract D3MSwapPool is ID3MPool { + struct FeeData { + uint128 buffer; // Keep a buffer in DAI for liquidity [wad] + uint24 tin1; // toll in under the buffer [bps] + uint24 tout1; // toll out under the buffer [bps] + uint24 tin2; // toll in over the buffer [bps] + uint24 tout2; // toll out over the buffer [bps] + } + // --- Data --- mapping (address => uint256) public wards; HubLike public hub; PipLike public pip; - uint256 public buffer; // Keep a buffer in DAI for liquidity [wad] - uint256 public tin1; // toll in under the buffer [wad] - uint256 public tout1; // toll out under the buffer [wad] - uint256 public tin2; // toll in over the buffer [wad] - uint256 public tout2; // toll out over the buffer [wad] + FeeData public feeData; uint256 public exited; bytes32 immutable public ilk; @@ -69,13 +73,14 @@ contract D3MSwapPool is ID3MPool { uint256 immutable private GEM_CONVERSION_FACTOR; + uint256 constant BPS = 10 ** 4; uint256 constant WAD = 10 ** 18; // --- Events --- event Rely(address indexed usr); event Deny(address indexed usr); - event File(bytes32 indexed what, uint256 data); - event File(bytes32 indexed what, uint256 tin, uint256 tout); + event File(bytes32 indexed what, uint128 data); + event File(bytes32 indexed what, uint24 tin, uint24 tout); event File(bytes32 indexed what, address data); event SellGem(address indexed owner, uint256 gems, uint256 dai); event BuyGem(address indexed owner, uint256 gems, uint256 dai); @@ -101,6 +106,15 @@ contract D3MSwapPool is ID3MPool { gem = TokenLike(_gem); vat.hope(_hub); + // Initialize all fees to zero + feeData = FeeData({ + buffer: 0, + tin1: uint24(BPS), + tout1: uint24(BPS), + tin2: uint24(BPS), + tout2: uint24(BPS) + }); + GEM_CONVERSION_FACTOR = 10 ** (18 - gem.decimals()); } @@ -116,26 +130,26 @@ contract D3MSwapPool is ID3MPool { emit Deny(usr); } - function file(bytes32 what, uint256 data) external auth { + function file(bytes32 what, uint128 data) external auth { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); - if (what == "buffer") buffer = data; + if (what == "buffer") feeData.buffer = data; else revert("D3MSwapPool/file-unrecognized-param"); emit File(what, data); } - function file(bytes32 what, uint256 tin, uint256 tout) external auth { + function file(bytes32 what, uint24 tin, uint24 tout) external auth { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); // We need to restrict tin/tout combinations to be less than 100% to avoid arbitrage opportunities. - require(tin * tout <= WAD * WAD, "D3MSwapPool/invalid-fees"); + require(uint256(tin) * uint256(tout) <= BPS * BPS, "D3MSwapPool/invalid-fees"); if (what == "fees1") { - tin1 = tin; - tout1 = tout; + feeData.tin1 = tin; + feeData.tout1 = tout; } else if (what == "fees2") { - tin2 = tin; - tout2 = tout; + feeData.tin2 = tin; + feeData.tout2 = tout; } else revert("D3MSwapPool/file-unrecognized-param"); emit File(what, tin, tout); @@ -155,9 +169,31 @@ contract D3MSwapPool is ID3MPool { emit File(what, data); } + // --- Getters --- + + function buffer() public view returns (uint256) { + return feeData.buffer; + } + + function tin1() public view returns (uint256) { + return feeData.tin1; + } + + function tout1() public view returns (uint256) { + return feeData.tout1; + } + + function tin2() public view returns (uint256) { + return feeData.tin2; + } + + function tout2() public view returns (uint256) { + return feeData.tout2; + } + // --- Pool Support --- - function deposit(uint256 wad) external override onlyHub { + function deposit(uint256) external override onlyHub { // Nothing to do } @@ -202,18 +238,18 @@ contract D3MSwapPool is ID3MPool { function previewSellGem(uint256 gemAmt) public view returns (uint256 daiAmt) { uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; uint256 daiBalance = dai.balanceOf(address(this)); - uint256 _buffer = buffer; - if (daiBalance <= _buffer) { + FeeData memory _feeData = feeData; + if (daiBalance <= _feeData.buffer) { // We are above the buffer so apply tin2 - daiAmt = gemValue * tin2 / WAD; + daiAmt = gemValue * _feeData.tin2 / BPS; } else { uint256 daiAvailableAtTin1; unchecked { - daiAvailableAtTin1 = daiBalance - _buffer; + daiAvailableAtTin1 = daiBalance - _feeData.buffer; } // We are below the buffer so could be a mix of tin1 and tin2 - uint256 daiAmtTin1 = gemValue * tin1 / WAD; + uint256 daiAmtTin1 = gemValue * _feeData.tin1 / BPS; if (daiAmtTin1 <= daiAvailableAtTin1) { // We are entirely in the tin1 region daiAmt = daiAmtTin1; @@ -223,7 +259,7 @@ contract D3MSwapPool is ID3MPool { unchecked { daiRemainder = daiAmtTin1 - daiAvailableAtTin1; } - daiAmt = daiAvailableAtTin1 + (daiRemainder * WAD / tin1) * tin2 / WAD; + daiAmt = daiAvailableAtTin1 + (daiRemainder * BPS / _feeData.tin1) * _feeData.tin2 / BPS; } } } @@ -231,27 +267,27 @@ contract D3MSwapPool is ID3MPool { function previewBuyGem(uint256 daiAmt) public view returns (uint256 gemAmt) { uint256 gemValue; uint256 daiBalance = dai.balanceOf(address(this)); - uint256 _buffer = buffer; - if (daiBalance >= _buffer) { + FeeData memory _feeData = feeData; + if (daiBalance >= _feeData.buffer) { // We are below the buffer so apply tout1 - gemValue = daiAmt * tout1 / WAD; + gemValue = daiAmt * _feeData.tout1 / BPS; } else { uint256 daiAvailableAtTout2; unchecked { - daiAvailableAtTout2 = _buffer - daiBalance; + daiAvailableAtTout2 = _feeData.buffer - daiBalance; } // We are above the buffer so could be a mix of tout1 and tout2 if (daiAmt <= daiAvailableAtTout2) { // We are entirely in the tout1 region - gemValue = daiAmt * tout2 / WAD; + gemValue = daiAmt * _feeData.tout2 / BPS; } else { // We are a mix between tout1 and tout2 uint256 daiRemainder; unchecked { daiRemainder = daiAmt - daiAvailableAtTout2; } - gemValue = daiAvailableAtTout2 * tout2 / WAD + daiRemainder * tout1 / WAD; + gemValue = daiAvailableAtTout2 * _feeData.tout2 / BPS + daiRemainder * _feeData.tout1 / BPS; } } gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * uint256(pip.read())); diff --git a/src/tests/pools/D3MSwapPool.t.sol b/src/tests/pools/D3MSwapPool.t.sol index 1edc5188..625dded8 100644 --- a/src/tests/pools/D3MSwapPool.t.sol +++ b/src/tests/pools/D3MSwapPool.t.sol @@ -43,7 +43,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { FakeEnd end; PipMock pip; - event File(bytes32 indexed what, uint256 tin, uint256 tout); + event File(bytes32 indexed what, uint24 tin, uint24 tout); event SellGem(address indexed owner, uint256 gems, uint256 dai); event BuyGem(address indexed owner, uint256 gems, uint256 dai); @@ -63,11 +63,12 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { d3mTestPool = address(swapPool = new D3MSwapPool(ILK, hub, address(dai), address(gem))); swapPool.file("pip", address(pip)); - swapPool.file("buffer", 10 ether); // 10 DAI buffer to switch between tin/tout1 and tin/tout2 + // 10 DAI buffer to switch between tin/tout1 and tin/tout2 + swapPool.file("buffer", 10 ether); // 5 bps negative wind fee (pay people to wind), 20 bps unwind fee - swapPool.file("fees1", 10005 * WAD / BPS, 9980 * WAD / BPS); + swapPool.file("fees1", 10005, 9980); // 10 bps fee after the buffer is reached, 8 bps negative fee (pay people to unwind) - swapPool.file("fees2", 9990 * WAD / BPS, 10008 * WAD / BPS); + swapPool.file("fees2", 9990, 10008); gem.approve(d3mTestPool, type(uint256).max); dai.approve(d3mTestPool, type(uint256).max); } @@ -80,11 +81,27 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { assertEq(address(swapPool.gem()), address(gem)); } - function test_file() public { - checkFileUint(d3mTestPool, contractName, ["buffer"]); + function test_file_addresses() public { checkFileAddress(d3mTestPool, contractName, ["hub", "pip"]); } + function test_file_buffer() public { + vm.expectRevert(abi.encodePacked(contractName, "/file-unrecognized-param")); + swapPool.file("an invalid value", 1); + + swapPool.file("buffer", 1); + + assertEq(swapPool.buffer(), 1); + + FakeVat(vat).cage(); + vm.expectRevert(abi.encodePacked(contractName, "/no-file-during-shutdown")); + swapPool.file("some value", 1); + + swapPool.deny(address(this)); + vm.expectRevert(abi.encodePacked(contractName, "/not-authorized")); + swapPool.file("some value", 1); + } + function test_file_fees() public { vm.expectRevert(abi.encodePacked(contractName, "/file-unrecognized-param")); swapPool.file("an invalid value", 1, 2); @@ -114,7 +131,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { function test_file_invalid_fees() public { vm.expectRevert(abi.encodePacked(contractName, "/invalid-fees")); - swapPool.file("fees1", WAD + 1, WAD); + swapPool.file("fees1", uint24(BPS + 1), uint24(BPS)); } function test_withdraw() public { @@ -201,7 +218,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { function test_previewSellGem_mixed_fees_exact_cancel() public { dai.transfer(d3mTestPool, 100 ether); - swapPool.file("fees2", WAD*WAD / swapPool.tin1(), swapPool.tin1()); + swapPool.file("fees2", uint24(BPS*BPS / swapPool.tin1()), uint24(swapPool.tin1())); // ~45 tokens will earn the 5bps fee, remainding ~45 pays the 5bps fee // Allow for a 1bps error due to rounding @@ -250,7 +267,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { function test_previewBuyGem_mixed_fees_exact_cancel() public { dai.transfer(d3mTestPool, 5 ether); - swapPool.file("fees2", swapPool.tout1(), WAD*WAD / swapPool.tout1()); + swapPool.file("fees2", uint24(swapPool.tout1()), uint24(BPS*BPS / swapPool.tout1())); // 10 DAI unwind should almost exactly cancel out // Allow for a 1bps error due to rounding From 8c9f5c8d8e345932dfb4259c078a7711673335af Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Wed, 22 Mar 2023 09:14:29 -0400 Subject: [PATCH 07/16] change buffer to be a ratio of gems to dai instead of absolute dai amount --- src/pools/D3MSwapPool.sol | 47 +++++++++++++++------------ src/tests/pools/D3MSwapPool.t.sol | 53 +++++++------------------------ 2 files changed, 39 insertions(+), 61 deletions(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index 48bc3862..7f21cdbf 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -51,11 +51,11 @@ interface EndLike { contract D3MSwapPool is ID3MPool { struct FeeData { - uint128 buffer; // Keep a buffer in DAI for liquidity [wad] - uint24 tin1; // toll in under the buffer [bps] - uint24 tout1; // toll out under the buffer [bps] - uint24 tin2; // toll in over the buffer [bps] - uint24 tout2; // toll out over the buffer [bps] + uint24 buffer; // where to place the fee1/fee2 change as ratio between gem and dai [bps] + uint24 tin1; // toll in under the buffer [bps] + uint24 tout1; // toll out under the buffer [bps] + uint24 tin2; // toll in over the buffer [bps] + uint24 tout2; // toll out over the buffer [bps] } // --- Data --- @@ -130,8 +130,9 @@ contract D3MSwapPool is ID3MPool { emit Deny(usr); } - function file(bytes32 what, uint128 data) external auth { + function file(bytes32 what, uint24 data) external auth { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + require(data <= BPS, "D3MSwapPool/invalid-buffer"); if (what == "buffer") feeData.buffer = data; else revert("D3MSwapPool/file-unrecognized-param"); @@ -210,7 +211,7 @@ contract D3MSwapPool is ID3MPool { function postDebtChange() external override {} - function assetBalance() public view override returns (uint256) { + function assetBalance() external view override returns (uint256) { return dai.balanceOf(address(this)) + gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; } @@ -236,16 +237,19 @@ contract D3MSwapPool is ID3MPool { // --- Swaps --- function previewSellGem(uint256 gemAmt) public view returns (uint256 daiAmt) { - uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; - uint256 daiBalance = dai.balanceOf(address(this)); FeeData memory _feeData = feeData; - if (daiBalance <= _feeData.buffer) { + uint256 pipValue = uint256(pip.read()); + uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 daiBalance = dai.balanceOf(address(this)); + uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 desiredGemBalance = _feeData.buffer * (daiBalance + gemBalance) / BPS; + if (gemBalance >= desiredGemBalance) { // We are above the buffer so apply tin2 daiAmt = gemValue * _feeData.tin2 / BPS; } else { uint256 daiAvailableAtTin1; unchecked { - daiAvailableAtTin1 = daiBalance - _feeData.buffer; + daiAvailableAtTin1 = desiredGemBalance - gemBalance; } // We are below the buffer so could be a mix of tin1 and tin2 @@ -265,32 +269,35 @@ contract D3MSwapPool is ID3MPool { } function previewBuyGem(uint256 daiAmt) public view returns (uint256 gemAmt) { + FeeData memory _feeData = feeData; + uint256 pipValue = uint256(pip.read()); uint256 gemValue; uint256 daiBalance = dai.balanceOf(address(this)); - FeeData memory _feeData = feeData; - if (daiBalance >= _feeData.buffer) { + uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 desiredGemBalance = _feeData.buffer * (daiBalance + gemBalance) / BPS; + if (gemBalance <= desiredGemBalance) { // We are below the buffer so apply tout1 gemValue = daiAmt * _feeData.tout1 / BPS; } else { - uint256 daiAvailableAtTout2; + uint256 gemsAvailableAtTout2; unchecked { - daiAvailableAtTout2 = _feeData.buffer - daiBalance; + gemsAvailableAtTout2 = gemBalance - desiredGemBalance; } // We are above the buffer so could be a mix of tout1 and tout2 - if (daiAmt <= daiAvailableAtTout2) { + if (daiAmt <= gemsAvailableAtTout2) { // We are entirely in the tout1 region gemValue = daiAmt * _feeData.tout2 / BPS; } else { // We are a mix between tout1 and tout2 - uint256 daiRemainder; + uint256 gemsRemainder; unchecked { - daiRemainder = daiAmt - daiAvailableAtTout2; + gemsRemainder = daiAmt - gemsAvailableAtTout2; } - gemValue = daiAvailableAtTout2 * _feeData.tout2 / BPS + daiRemainder * _feeData.tout1 / BPS; + gemValue = gemsAvailableAtTout2 * _feeData.tout2 / BPS + gemsRemainder * _feeData.tout1 / BPS; } } - gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * uint256(pip.read())); + gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * pipValue); } function sellGem(address usr, uint256 gemAmt, uint256 minDaiAmt) external returns (uint256 daiAmt) { diff --git a/src/tests/pools/D3MSwapPool.t.sol b/src/tests/pools/D3MSwapPool.t.sol index 625dded8..b0a50413 100644 --- a/src/tests/pools/D3MSwapPool.t.sol +++ b/src/tests/pools/D3MSwapPool.t.sol @@ -63,8 +63,8 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { d3mTestPool = address(swapPool = new D3MSwapPool(ILK, hub, address(dai), address(gem))); swapPool.file("pip", address(pip)); - // 10 DAI buffer to switch between tin/tout1 and tin/tout2 - swapPool.file("buffer", 10 ether); + // Set the fee switch to 90% (targeting 90% of the swap pool in gems) + swapPool.file("buffer", 9000); // 5 bps negative wind fee (pay people to wind), 20 bps unwind fee swapPool.file("fees1", 10005, 9980); // 10 bps fee after the buffer is reached, 8 bps negative fee (pay people to unwind) @@ -102,6 +102,11 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { swapPool.file("some value", 1); } + function test_file_invalid_buffer() public { + vm.expectRevert(abi.encodePacked(contractName, "/invalid-buffer")); + swapPool.file("buffer", uint24(BPS + 1)); + } + function test_file_fees() public { vm.expectRevert(abi.encodePacked(contractName, "/file-unrecognized-param")); swapPool.file("an invalid value", 1, 2); @@ -202,7 +207,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { } function test_previewSellGem_over_buffer() public { - swapPool.file("buffer", 100 ether); + swapPool.file("buffer", 0); dai.transfer(d3mTestPool, 100 ether); // 10 tokens @ $2 / unit + 10bps fee = 19.98 @@ -225,25 +230,8 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { assertApproxEqRel(swapPool.previewSellGem(90 * 1e6), 180 ether, WAD / 10000); } - function test_previewSellGem_buffer_zero_dai_zero() public { - swapPool.file("buffer", 0); - - // 10 tokens @ $2 / unit + 10bps fee = 19.98 - assertEq(swapPool.previewSellGem(10 * 1e6), 19.980 ether); - } - - function test_previewSellGem_buffer_zero() public { - swapPool.file("buffer", 0); - dai.transfer(d3mTestPool, 10 ether); - - assertEq(swapPool.previewSellGem(10 * 1e6), 19994992503748125936); - } - - function test_previewSellGem_dai_zero() public { - assertEq(swapPool.previewSellGem(10 * 1e6), 19.980 ether); - } - function test_previewBuyGem_over_buffer() public { + gem.transfer(d3mTestPool, 100 * 1e6); dai.transfer(d3mTestPool, 5 ether); // 4 DAI + 8bps payment = 2.003 tokens @@ -262,7 +250,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { // 5 of the DAI gets paid the 8bps fee, the other 5 pays the 20bps fee // Result is slightly less than 5 tokens - assertEq(swapPool.previewBuyGem(10 ether), 4997000); + assertEq(swapPool.previewBuyGem(10 ether), 4990000); } function test_previewBuyGem_mixed_fees_exact_cancel() public { @@ -270,25 +258,8 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { swapPool.file("fees2", uint24(swapPool.tout1()), uint24(BPS*BPS / swapPool.tout1())); // 10 DAI unwind should almost exactly cancel out - // Allow for a 1bps error due to rounding - assertApproxEqRel(swapPool.previewBuyGem(10 ether), 5 * 1e6, WAD / 10000); - } - - function test_previewBuyGem_buffer_zero_dai_zero() public { - swapPool.file("buffer", 0); - - assertEq(swapPool.previewBuyGem(20 ether), 9.98 * 1e6); - } - - function test_previewBuyGem_buffer_zero() public { - swapPool.file("buffer", 0); - dai.transfer(d3mTestPool, 10 ether); - - assertEq(swapPool.previewBuyGem(20 ether), 9.98 * 1e6); - } - - function test_previewBuyGem_dai_zero() public { - assertEq(swapPool.previewBuyGem(4 ether), 2.0016 * 1e6); + // Allow for a 1% error due to rounding + assertApproxEqRel(swapPool.previewBuyGem(10 ether), 5 * 1e6, WAD / 100); } function test_sellGem() public { From 7678e99247ec7da65a1187d47128fc90924d896d Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Wed, 22 Mar 2023 09:28:25 -0400 Subject: [PATCH 08/16] split oracles into market price, buy and sell --- src/pools/D3MSwapPool.sol | 10 ++++++++-- src/tests/pools/D3MSwapPool.t.sol | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index 7f21cdbf..4bf27179 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -63,6 +63,8 @@ contract D3MSwapPool is ID3MPool { HubLike public hub; PipLike public pip; + PipLike public sellGemPip; + PipLike public buyGemPip; FeeData public feeData; uint256 public exited; @@ -165,6 +167,10 @@ contract D3MSwapPool is ID3MPool { vat.hope(data); } else if (what == "pip") { pip = PipLike(data); + } else if (what == "sellGemPip") { + sellGemPip = PipLike(data); + } else if (what == "buyGemPip") { + buyGemPip = PipLike(data); } else revert("D3MSwapPool/file-unrecognized-param"); emit File(what, data); @@ -238,7 +244,7 @@ contract D3MSwapPool is ID3MPool { function previewSellGem(uint256 gemAmt) public view returns (uint256 daiAmt) { FeeData memory _feeData = feeData; - uint256 pipValue = uint256(pip.read()); + uint256 pipValue = uint256(sellGemPip.read()); uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * pipValue / WAD; uint256 daiBalance = dai.balanceOf(address(this)); uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; @@ -270,7 +276,7 @@ contract D3MSwapPool is ID3MPool { function previewBuyGem(uint256 daiAmt) public view returns (uint256 gemAmt) { FeeData memory _feeData = feeData; - uint256 pipValue = uint256(pip.read()); + uint256 pipValue = uint256(buyGemPip.read()); uint256 gemValue; uint256 daiBalance = dai.balanceOf(address(this)); uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; diff --git a/src/tests/pools/D3MSwapPool.t.sol b/src/tests/pools/D3MSwapPool.t.sol index b0a50413..d8069e84 100644 --- a/src/tests/pools/D3MSwapPool.t.sol +++ b/src/tests/pools/D3MSwapPool.t.sol @@ -62,6 +62,8 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { d3mTestPool = address(swapPool = new D3MSwapPool(ILK, hub, address(dai), address(gem))); swapPool.file("pip", address(pip)); + swapPool.file("sellGemPip", address(pip)); + swapPool.file("buyGemPip", address(pip)); // Set the fee switch to 90% (targeting 90% of the swap pool in gems) swapPool.file("buffer", 9000); @@ -82,7 +84,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { } function test_file_addresses() public { - checkFileAddress(d3mTestPool, contractName, ["hub", "pip"]); + checkFileAddress(d3mTestPool, contractName, ["hub", "pip", "sellGemPip", "buyGemPip"]); } function test_file_buffer() public { From 5a56c50a4bb0d577bd8085f4fe2394b2a89fc00b Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Wed, 22 Mar 2023 09:33:36 -0400 Subject: [PATCH 09/16] fix event --- src/pools/D3MSwapPool.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index 4bf27179..4e9cf942 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -81,7 +81,7 @@ contract D3MSwapPool is ID3MPool { // --- Events --- event Rely(address indexed usr); event Deny(address indexed usr); - event File(bytes32 indexed what, uint128 data); + event File(bytes32 indexed what, uint24 data); event File(bytes32 indexed what, uint24 tin, uint24 tout); event File(bytes32 indexed what, address data); event SellGem(address indexed owner, uint256 gems, uint256 dai); From 426a52b729c4378e4314b3240959cda26ad0488e Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Wed, 22 Mar 2023 17:13:58 -0400 Subject: [PATCH 10/16] public -> external --- src/pools/D3MSwapPool.sol | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index 4e9cf942..1177bbb2 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -178,23 +178,23 @@ contract D3MSwapPool is ID3MPool { // --- Getters --- - function buffer() public view returns (uint256) { + function buffer() external view returns (uint256) { return feeData.buffer; } - function tin1() public view returns (uint256) { + function tin1() external view returns (uint256) { return feeData.tin1; } - function tout1() public view returns (uint256) { + function tout1() external view returns (uint256) { return feeData.tout1; } - function tin2() public view returns (uint256) { + function tin2() external view returns (uint256) { return feeData.tin2; } - function tout2() public view returns (uint256) { + function tout2() external view returns (uint256) { return feeData.tout2; } From 5a9d675fafdd85c0fe3d7bfead6cc1d9b6734620 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Fri, 31 Mar 2023 09:18:03 -0400 Subject: [PATCH 11/16] change buffer to ratio --- src/pools/D3MSwapPool.sol | 34 +++++++++++++++---------------- src/tests/pools/D3MSwapPool.t.sol | 26 +++++++++++------------ 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index 1177bbb2..287c54d1 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -46,16 +46,16 @@ interface EndLike { /** * @title D3M Swap Pool - * @notice Swap an asset for DAI. Fees vary based on whether the pool is above or below the buffer. + * @notice Swap an asset for DAI. Fees vary based on whether the pool is above or below the target ratio. */ contract D3MSwapPool is ID3MPool { struct FeeData { - uint24 buffer; // where to place the fee1/fee2 change as ratio between gem and dai [bps] - uint24 tin1; // toll in under the buffer [bps] - uint24 tout1; // toll out under the buffer [bps] - uint24 tin2; // toll in over the buffer [bps] - uint24 tout2; // toll out over the buffer [bps] + uint24 ratio; // where to place the fee1/fee2 change as ratio between gem and dai [bps] + uint24 tin1; // toll in under the ratio [bps] + uint24 tout1; // toll out under the ratio [bps] + uint24 tin2; // toll in over the ratio [bps] + uint24 tout2; // toll out over the ratio [bps] } // --- Data --- @@ -110,7 +110,7 @@ contract D3MSwapPool is ID3MPool { // Initialize all fees to zero feeData = FeeData({ - buffer: 0, + ratio: 0, tin1: uint24(BPS), tout1: uint24(BPS), tin2: uint24(BPS), @@ -134,9 +134,9 @@ contract D3MSwapPool is ID3MPool { function file(bytes32 what, uint24 data) external auth { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); - require(data <= BPS, "D3MSwapPool/invalid-buffer"); + require(data <= BPS, "D3MSwapPool/invalid-ratio"); - if (what == "buffer") feeData.buffer = data; + if (what == "ratio") feeData.ratio = data; else revert("D3MSwapPool/file-unrecognized-param"); emit File(what, data); @@ -178,8 +178,8 @@ contract D3MSwapPool is ID3MPool { // --- Getters --- - function buffer() external view returns (uint256) { - return feeData.buffer; + function ratio() external view returns (uint256) { + return feeData.ratio; } function tin1() external view returns (uint256) { @@ -248,9 +248,9 @@ contract D3MSwapPool is ID3MPool { uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * pipValue / WAD; uint256 daiBalance = dai.balanceOf(address(this)); uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; - uint256 desiredGemBalance = _feeData.buffer * (daiBalance + gemBalance) / BPS; + uint256 desiredGemBalance = _feeData.ratio * (daiBalance + gemBalance) / BPS; if (gemBalance >= desiredGemBalance) { - // We are above the buffer so apply tin2 + // We are above the ratio so apply tin2 daiAmt = gemValue * _feeData.tin2 / BPS; } else { uint256 daiAvailableAtTin1; @@ -258,7 +258,7 @@ contract D3MSwapPool is ID3MPool { daiAvailableAtTin1 = desiredGemBalance - gemBalance; } - // We are below the buffer so could be a mix of tin1 and tin2 + // We are below the ratio so could be a mix of tin1 and tin2 uint256 daiAmtTin1 = gemValue * _feeData.tin1 / BPS; if (daiAmtTin1 <= daiAvailableAtTin1) { // We are entirely in the tin1 region @@ -280,9 +280,9 @@ contract D3MSwapPool is ID3MPool { uint256 gemValue; uint256 daiBalance = dai.balanceOf(address(this)); uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; - uint256 desiredGemBalance = _feeData.buffer * (daiBalance + gemBalance) / BPS; + uint256 desiredGemBalance = _feeData.ratio * (daiBalance + gemBalance) / BPS; if (gemBalance <= desiredGemBalance) { - // We are below the buffer so apply tout1 + // We are below the ratio so apply tout1 gemValue = daiAmt * _feeData.tout1 / BPS; } else { uint256 gemsAvailableAtTout2; @@ -290,7 +290,7 @@ contract D3MSwapPool is ID3MPool { gemsAvailableAtTout2 = gemBalance - desiredGemBalance; } - // We are above the buffer so could be a mix of tout1 and tout2 + // We are above the ratio so could be a mix of tout1 and tout2 if (daiAmt <= gemsAvailableAtTout2) { // We are entirely in the tout1 region gemValue = daiAmt * _feeData.tout2 / BPS; diff --git a/src/tests/pools/D3MSwapPool.t.sol b/src/tests/pools/D3MSwapPool.t.sol index d8069e84..399da3d5 100644 --- a/src/tests/pools/D3MSwapPool.t.sol +++ b/src/tests/pools/D3MSwapPool.t.sol @@ -66,10 +66,10 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { swapPool.file("buyGemPip", address(pip)); // Set the fee switch to 90% (targeting 90% of the swap pool in gems) - swapPool.file("buffer", 9000); + swapPool.file("ratio", 9000); // 5 bps negative wind fee (pay people to wind), 20 bps unwind fee swapPool.file("fees1", 10005, 9980); - // 10 bps fee after the buffer is reached, 8 bps negative fee (pay people to unwind) + // 10 bps fee after the ratio is reached, 8 bps negative fee (pay people to unwind) swapPool.file("fees2", 9990, 10008); gem.approve(d3mTestPool, type(uint256).max); dai.approve(d3mTestPool, type(uint256).max); @@ -87,13 +87,13 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { checkFileAddress(d3mTestPool, contractName, ["hub", "pip", "sellGemPip", "buyGemPip"]); } - function test_file_buffer() public { + function test_file_ratio() public { vm.expectRevert(abi.encodePacked(contractName, "/file-unrecognized-param")); swapPool.file("an invalid value", 1); - swapPool.file("buffer", 1); + swapPool.file("ratio", 1); - assertEq(swapPool.buffer(), 1); + assertEq(swapPool.ratio(), 1); FakeVat(vat).cage(); vm.expectRevert(abi.encodePacked(contractName, "/no-file-during-shutdown")); @@ -104,9 +104,9 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { swapPool.file("some value", 1); } - function test_file_invalid_buffer() public { - vm.expectRevert(abi.encodePacked(contractName, "/invalid-buffer")); - swapPool.file("buffer", uint24(BPS + 1)); + function test_file_invalid_ratio() public { + vm.expectRevert(abi.encodePacked(contractName, "/invalid-ratio")); + swapPool.file("ratio", uint24(BPS + 1)); } function test_file_fees() public { @@ -201,15 +201,15 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { assertEq(swapPool.maxWithdraw(), 100 ether); } - function test_previewSellGem_under_buffer() public { + function test_previewSellGem_under_ratio() public { dai.transfer(d3mTestPool, 100 ether); // 10 tokens @ $2 / unit + 5bps payment = 20.01 assertEq(swapPool.previewSellGem(10 * 1e6), 20.010 ether); } - function test_previewSellGem_over_buffer() public { - swapPool.file("buffer", 0); + function test_previewSellGem_over_ratio() public { + swapPool.file("ratio", 0); dai.transfer(d3mTestPool, 100 ether); // 10 tokens @ $2 / unit + 10bps fee = 19.98 @@ -232,7 +232,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { assertApproxEqRel(swapPool.previewSellGem(90 * 1e6), 180 ether, WAD / 10000); } - function test_previewBuyGem_over_buffer() public { + function test_previewBuyGem_over_ratio() public { gem.transfer(d3mTestPool, 100 * 1e6); dai.transfer(d3mTestPool, 5 ether); @@ -240,7 +240,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { assertEq(swapPool.previewBuyGem(4 ether), 2.0016 * 1e6); } - function test_previewBuyGem_under_buffer() public { + function test_previewBuyGem_under_ratio() public { dai.transfer(d3mTestPool, 100 ether); // 20 DAI + 20bps fee = 9.96 tokens From d04f35842606d671b975827a267749e0f66b7347 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Mon, 10 Apr 2023 14:06:03 +0200 Subject: [PATCH 12/16] quit should send dai as well --- src/pools/D3MSwapPool.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index 287c54d1..d178d0a9 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -211,6 +211,7 @@ contract D3MSwapPool is ID3MPool { function quit(address dst) external override auth { require(vat.live() == 1, "D3MSwapPool/no-quit-during-shutdown"); require(gem.transfer(dst, gem.balanceOf(address(this))), "D3MSwapPool/transfer-failed"); + dai.transfer(dst, dai.balanceOf(address(this))); } function preDebtChange() external override {} From b3b4e88b32151724e1f02bfbfa07500c7b9e4515 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 11 Apr 2023 10:47:58 +0200 Subject: [PATCH 13/16] split swap pool into base/implementing contracts --- src/pools/D3MKinkedFeeSwapPool.sol | 171 ++++++++++++++++++ src/pools/D3MSwapPool.sol | 141 +-------------- ...pPool.t.sol => D3MKinkedFeeSwapPool.t.sol} | 8 +- 3 files changed, 181 insertions(+), 139 deletions(-) create mode 100644 src/pools/D3MKinkedFeeSwapPool.sol rename src/tests/pools/{D3MSwapPool.t.sol => D3MKinkedFeeSwapPool.t.sol} (97%) diff --git a/src/pools/D3MKinkedFeeSwapPool.sol b/src/pools/D3MKinkedFeeSwapPool.sol new file mode 100644 index 00000000..54feb668 --- /dev/null +++ b/src/pools/D3MKinkedFeeSwapPool.sol @@ -0,0 +1,171 @@ +// 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 "./D3MSwapPool.sol"; + +/** + * @title D3M Kinked Fee Swap Pool + * @notice Swap an asset for DAI. Fees vary based on whether the pool is above or below the target ratio. + */ +contract D3MKinkedFeeSwapPool is D3MSwapPool { + + struct FeeData { + uint24 ratio; // where to place the fee1/fee2 change as ratio between gem and dai [bps] + uint24 tin1; // toll in under the ratio [bps] + uint24 tout1; // toll out under the ratio [bps] + uint24 tin2; // toll in over the ratio [bps] + uint24 tout2; // toll out over the ratio [bps] + } + + // --- Data --- + FeeData public feeData; + + uint256 constant internal BPS = 10 ** 4; + + // --- Events --- + event File(bytes32 indexed what, uint24 data); + event File(bytes32 indexed what, uint24 tin, uint24 tout); + + constructor(bytes32 _ilk, address _hub, address _dai, address _gem) D3MSwapPool(_ilk, _hub, _dai, _gem) { + // Initialize all fees to zero + feeData = FeeData({ + ratio: 0, + tin1: uint24(BPS), + tout1: uint24(BPS), + tin2: uint24(BPS), + tout2: uint24(BPS) + }); + } + + // --- Administration --- + + function file(bytes32 what, uint24 data) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + require(data <= BPS, "D3MSwapPool/invalid-ratio"); + + if (what == "ratio") feeData.ratio = data; + else revert("D3MSwapPool/file-unrecognized-param"); + + emit File(what, data); + } + + function file(bytes32 what, uint24 tin, uint24 tout) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + // We need to restrict tin/tout combinations to be less than 100% to avoid arbitrage opportunities. + require(uint256(tin) * uint256(tout) <= BPS * BPS, "D3MSwapPool/invalid-fees"); + + if (what == "fees1") { + feeData.tin1 = tin; + feeData.tout1 = tout; + } else if (what == "fees2") { + feeData.tin2 = tin; + feeData.tout2 = tout; + } else revert("D3MSwapPool/file-unrecognized-param"); + + emit File(what, tin, tout); + } + + // --- Getters --- + + function ratio() external view returns (uint256) { + return feeData.ratio; + } + + function tin1() external view returns (uint256) { + return feeData.tin1; + } + + function tout1() external view returns (uint256) { + return feeData.tout1; + } + + function tin2() external view returns (uint256) { + return feeData.tin2; + } + + function tout2() external view returns (uint256) { + return feeData.tout2; + } + + // --- Swaps --- + + function previewSellGem(uint256 gemAmt) public override view returns (uint256 daiAmt) { + FeeData memory _feeData = feeData; + uint256 pipValue = uint256(sellGemPip.read()); + uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 daiBalance = dai.balanceOf(address(this)); + uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 desiredGemBalance = _feeData.ratio * (daiBalance + gemBalance) / BPS; + if (gemBalance >= desiredGemBalance) { + // We are above the ratio so apply tin2 + daiAmt = gemValue * _feeData.tin2 / BPS; + } else { + uint256 daiAvailableAtTin1; + unchecked { + daiAvailableAtTin1 = desiredGemBalance - gemBalance; + } + + // We are below the ratio so could be a mix of tin1 and tin2 + uint256 daiAmtTin1 = gemValue * _feeData.tin1 / BPS; + if (daiAmtTin1 <= daiAvailableAtTin1) { + // We are entirely in the tin1 region + daiAmt = daiAmtTin1; + } else { + // We are a mix between tin1 and tin2 + uint256 daiRemainder; + unchecked { + daiRemainder = daiAmtTin1 - daiAvailableAtTin1; + } + daiAmt = daiAvailableAtTin1 + (daiRemainder * BPS / _feeData.tin1) * _feeData.tin2 / BPS; + } + } + } + + function previewBuyGem(uint256 daiAmt) public override view returns (uint256 gemAmt) { + FeeData memory _feeData = feeData; + uint256 pipValue = uint256(buyGemPip.read()); + uint256 gemValue; + uint256 daiBalance = dai.balanceOf(address(this)); + uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 desiredGemBalance = _feeData.ratio * (daiBalance + gemBalance) / BPS; + if (gemBalance <= desiredGemBalance) { + // We are below the ratio so apply tout1 + gemValue = daiAmt * _feeData.tout1 / BPS; + } else { + uint256 gemsAvailableAtTout2; + unchecked { + gemsAvailableAtTout2 = gemBalance - desiredGemBalance; + } + + // We are above the ratio so could be a mix of tout1 and tout2 + if (daiAmt <= gemsAvailableAtTout2) { + // We are entirely in the tout1 region + gemValue = daiAmt * _feeData.tout2 / BPS; + } else { + // We are a mix between tout1 and tout2 + uint256 gemsRemainder; + unchecked { + gemsRemainder = daiAmt - gemsAvailableAtTout2; + } + gemValue = gemsAvailableAtTout2 * _feeData.tout2 / BPS + gemsRemainder * _feeData.tout1 / BPS; + } + } + gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * pipValue); + } + +} diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index d178d0a9..a6c581ab 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -46,17 +46,9 @@ interface EndLike { /** * @title D3M Swap Pool - * @notice Swap an asset for DAI. Fees vary based on whether the pool is above or below the target ratio. + * @notice Swap an asset for DAI. Base contract to be extended to implement fee logic. */ -contract D3MSwapPool is ID3MPool { - - struct FeeData { - uint24 ratio; // where to place the fee1/fee2 change as ratio between gem and dai [bps] - uint24 tin1; // toll in under the ratio [bps] - uint24 tout1; // toll out under the ratio [bps] - uint24 tin2; // toll in over the ratio [bps] - uint24 tout2; // toll out over the ratio [bps] - } +abstract contract D3MSwapPool is ID3MPool { // --- Data --- mapping (address => uint256) public wards; @@ -65,7 +57,6 @@ contract D3MSwapPool is ID3MPool { PipLike public pip; PipLike public sellGemPip; PipLike public buyGemPip; - FeeData public feeData; uint256 public exited; bytes32 immutable public ilk; @@ -73,16 +64,13 @@ contract D3MSwapPool is ID3MPool { TokenLike immutable public dai; TokenLike immutable public gem; - uint256 immutable private GEM_CONVERSION_FACTOR; + uint256 constant internal WAD = 10 ** 18; - uint256 constant BPS = 10 ** 4; - uint256 constant WAD = 10 ** 18; + uint256 immutable internal GEM_CONVERSION_FACTOR; // --- Events --- event Rely(address indexed usr); event Deny(address indexed usr); - event File(bytes32 indexed what, uint24 data); - event File(bytes32 indexed what, uint24 tin, uint24 tout); event File(bytes32 indexed what, address data); event SellGem(address indexed owner, uint256 gems, uint256 dai); event BuyGem(address indexed owner, uint256 gems, uint256 dai); @@ -108,15 +96,6 @@ contract D3MSwapPool is ID3MPool { gem = TokenLike(_gem); vat.hope(_hub); - // Initialize all fees to zero - feeData = FeeData({ - ratio: 0, - tin1: uint24(BPS), - tout1: uint24(BPS), - tin2: uint24(BPS), - tout2: uint24(BPS) - }); - GEM_CONVERSION_FACTOR = 10 ** (18 - gem.decimals()); } @@ -132,32 +111,6 @@ contract D3MSwapPool is ID3MPool { emit Deny(usr); } - function file(bytes32 what, uint24 data) external auth { - require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); - require(data <= BPS, "D3MSwapPool/invalid-ratio"); - - if (what == "ratio") feeData.ratio = data; - else revert("D3MSwapPool/file-unrecognized-param"); - - emit File(what, data); - } - - function file(bytes32 what, uint24 tin, uint24 tout) external auth { - require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); - // We need to restrict tin/tout combinations to be less than 100% to avoid arbitrage opportunities. - require(uint256(tin) * uint256(tout) <= BPS * BPS, "D3MSwapPool/invalid-fees"); - - if (what == "fees1") { - feeData.tin1 = tin; - feeData.tout1 = tout; - } else if (what == "fees2") { - feeData.tin2 = tin; - feeData.tout2 = tout; - } else revert("D3MSwapPool/file-unrecognized-param"); - - emit File(what, tin, tout); - } - function file(bytes32 what, address data) external auth { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); @@ -176,28 +129,6 @@ contract D3MSwapPool is ID3MPool { emit File(what, data); } - // --- Getters --- - - function ratio() external view returns (uint256) { - return feeData.ratio; - } - - function tin1() external view returns (uint256) { - return feeData.tin1; - } - - function tout1() external view returns (uint256) { - return feeData.tout1; - } - - function tin2() external view returns (uint256) { - return feeData.tin2; - } - - function tout2() external view returns (uint256) { - return feeData.tout2; - } - // --- Pool Support --- function deposit(uint256) external override onlyHub { @@ -243,69 +174,9 @@ contract D3MSwapPool is ID3MPool { // --- Swaps --- - function previewSellGem(uint256 gemAmt) public view returns (uint256 daiAmt) { - FeeData memory _feeData = feeData; - uint256 pipValue = uint256(sellGemPip.read()); - uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * pipValue / WAD; - uint256 daiBalance = dai.balanceOf(address(this)); - uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; - uint256 desiredGemBalance = _feeData.ratio * (daiBalance + gemBalance) / BPS; - if (gemBalance >= desiredGemBalance) { - // We are above the ratio so apply tin2 - daiAmt = gemValue * _feeData.tin2 / BPS; - } else { - uint256 daiAvailableAtTin1; - unchecked { - daiAvailableAtTin1 = desiredGemBalance - gemBalance; - } - - // We are below the ratio so could be a mix of tin1 and tin2 - uint256 daiAmtTin1 = gemValue * _feeData.tin1 / BPS; - if (daiAmtTin1 <= daiAvailableAtTin1) { - // We are entirely in the tin1 region - daiAmt = daiAmtTin1; - } else { - // We are a mix between tin1 and tin2 - uint256 daiRemainder; - unchecked { - daiRemainder = daiAmtTin1 - daiAvailableAtTin1; - } - daiAmt = daiAvailableAtTin1 + (daiRemainder * BPS / _feeData.tin1) * _feeData.tin2 / BPS; - } - } - } + function previewSellGem(uint256 gemAmt) public virtual view returns (uint256 daiAmt); - function previewBuyGem(uint256 daiAmt) public view returns (uint256 gemAmt) { - FeeData memory _feeData = feeData; - uint256 pipValue = uint256(buyGemPip.read()); - uint256 gemValue; - uint256 daiBalance = dai.balanceOf(address(this)); - uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; - uint256 desiredGemBalance = _feeData.ratio * (daiBalance + gemBalance) / BPS; - if (gemBalance <= desiredGemBalance) { - // We are below the ratio so apply tout1 - gemValue = daiAmt * _feeData.tout1 / BPS; - } else { - uint256 gemsAvailableAtTout2; - unchecked { - gemsAvailableAtTout2 = gemBalance - desiredGemBalance; - } - - // We are above the ratio so could be a mix of tout1 and tout2 - if (daiAmt <= gemsAvailableAtTout2) { - // We are entirely in the tout1 region - gemValue = daiAmt * _feeData.tout2 / BPS; - } else { - // We are a mix between tout1 and tout2 - uint256 gemsRemainder; - unchecked { - gemsRemainder = daiAmt - gemsAvailableAtTout2; - } - gemValue = gemsAvailableAtTout2 * _feeData.tout2 / BPS + gemsRemainder * _feeData.tout1 / BPS; - } - } - gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * pipValue); - } + function previewBuyGem(uint256 daiAmt) public virtual view returns (uint256 gemAmt); function sellGem(address usr, uint256 gemAmt, uint256 minDaiAmt) external returns (uint256 daiAmt) { daiAmt = previewSellGem(gemAmt); diff --git a/src/tests/pools/D3MSwapPool.t.sol b/src/tests/pools/D3MKinkedFeeSwapPool.t.sol similarity index 97% rename from src/tests/pools/D3MSwapPool.t.sol rename to src/tests/pools/D3MKinkedFeeSwapPool.t.sol index 399da3d5..8b036674 100644 --- a/src/tests/pools/D3MSwapPool.t.sol +++ b/src/tests/pools/D3MKinkedFeeSwapPool.t.sol @@ -19,7 +19,7 @@ pragma solidity ^0.8.14; import { D3MPoolBaseTest, FakeHub, FakeVat, FakeEnd, DaiLike } from "./D3MPoolBase.t.sol"; import { D3MTestGem } from "../stubs/D3MTestGem.sol"; -import { D3MSwapPool } from "../../pools/D3MSwapPool.sol"; +import { D3MKinkedFeeSwapPool } from "../../pools/D3MKinkedFeeSwapPool.sol"; contract PipMock { uint256 public val; @@ -34,11 +34,11 @@ contract PipMock { } } -contract D3MSwapPoolTest is D3MPoolBaseTest { +contract D3MKinkedFeeSwapPoolTest is D3MPoolBaseTest { bytes32 constant ILK = "TEST-ILK"; - D3MSwapPool swapPool; + D3MKinkedFeeSwapPool swapPool; D3MTestGem gem; FakeEnd end; PipMock pip; @@ -60,7 +60,7 @@ contract D3MSwapPoolTest is D3MPoolBaseTest { pip = new PipMock(); pip.poke(2e18); // Gem is worth $2 / unit - d3mTestPool = address(swapPool = new D3MSwapPool(ILK, hub, address(dai), address(gem))); + d3mTestPool = address(swapPool = new D3MKinkedFeeSwapPool(ILK, hub, address(dai), address(gem))); swapPool.file("pip", address(pip)); swapPool.file("sellGemPip", address(pip)); swapPool.file("buyGemPip", address(pip)); From 7332c4951926a904b9135b0aef7001e2096a71ed Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 11 Apr 2023 12:01:44 +0200 Subject: [PATCH 14/16] add a whitelisted swap pool for MIP65/MIP90 constructs --- README.md | 9 ++ src/pools/D3MKinkedFeeSwapPool.sol | 4 +- src/pools/D3MSwapPool.sol | 6 +- src/pools/D3MWhitelistedSwapPool.sol | 158 +++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 src/pools/D3MWhitelistedSwapPool.sol diff --git a/README.md b/README.md index e79a417a..7805e901 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,12 @@ Below is a configurable parameter for the Compound DAI D3M: - `barb` [wad] - The target borrow rate per block on Compound for the DAI market. This module will aim to enforce that borrow limit. Any Comp that is accured can be permissionlessly collected into the pause proxy by calling `collect()`. + +## Plug and Play License + +The following contracts are available under a Plug and Play license: + +#### D3MSwapPool, D3MKinkedFeeSwapPool, D3MWhitelistedSwapPool + +Owner: sparkprotocol.eth +Revenue Share: 10% of net earnings after any fees by SubDAO diff --git a/src/pools/D3MKinkedFeeSwapPool.sol b/src/pools/D3MKinkedFeeSwapPool.sol index 54feb668..9054d942 100644 --- a/src/pools/D3MKinkedFeeSwapPool.sol +++ b/src/pools/D3MKinkedFeeSwapPool.sol @@ -104,7 +104,7 @@ contract D3MKinkedFeeSwapPool is D3MSwapPool { // --- Swaps --- - function previewSellGem(uint256 gemAmt) public override view returns (uint256 daiAmt) { + function previewSellGem(uint256 gemAmt) public view override returns (uint256 daiAmt) { FeeData memory _feeData = feeData; uint256 pipValue = uint256(sellGemPip.read()); uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * pipValue / WAD; @@ -136,7 +136,7 @@ contract D3MKinkedFeeSwapPool is D3MSwapPool { } } - function previewBuyGem(uint256 daiAmt) public override view returns (uint256 gemAmt) { + function previewBuyGem(uint256 daiAmt) public view override returns (uint256 gemAmt) { FeeData memory _feeData = feeData; uint256 pipValue = uint256(buyGemPip.read()); uint256 gemValue; diff --git a/src/pools/D3MSwapPool.sol b/src/pools/D3MSwapPool.sol index a6c581ab..7ecc8e05 100644 --- a/src/pools/D3MSwapPool.sol +++ b/src/pools/D3MSwapPool.sol @@ -149,7 +149,7 @@ abstract contract D3MSwapPool is ID3MPool { function postDebtChange() external override {} - function assetBalance() external view override returns (uint256) { + function assetBalance() external view virtual returns (uint256) { return dai.balanceOf(address(this)) + gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; } @@ -174,9 +174,9 @@ abstract contract D3MSwapPool is ID3MPool { // --- Swaps --- - function previewSellGem(uint256 gemAmt) public virtual view returns (uint256 daiAmt); + function previewSellGem(uint256 gemAmt) public view virtual returns (uint256 daiAmt); - function previewBuyGem(uint256 daiAmt) public virtual view returns (uint256 gemAmt); + function previewBuyGem(uint256 daiAmt) public view virtual returns (uint256 gemAmt); function sellGem(address usr, uint256 gemAmt, uint256 minDaiAmt) external returns (uint256 daiAmt) { daiAmt = previewSellGem(gemAmt); diff --git a/src/pools/D3MWhitelistedSwapPool.sol b/src/pools/D3MWhitelistedSwapPool.sol new file mode 100644 index 00000000..dece507d --- /dev/null +++ b/src/pools/D3MWhitelistedSwapPool.sol @@ -0,0 +1,158 @@ +// 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 "./D3MSwapPool.sol"; +import {ID3MPlan} from "../plans/ID3MPlan.sol"; + +/** + * @title D3M Whitelisted Swap Pool + * @notice Whitelisted addresses can remove gems from this pool. + * @dev DAI to GEM swaps only occur in one direction depending on if the outstanding debt is lower + * or higher than the debt ceiling. + */ +contract D3MWhitelistedSwapPool is D3MSwapPool { + + struct FeeData { + uint24 tin; // toll in [bps] + uint24 tout; // toll out [bps] + } + + // --- Data --- + mapping (address => uint256) public operators; + + FeeData public feeData; + ID3MPlan public plan; + uint256 public gemsWithdrawn; + + uint256 constant internal BPS = 10 ** 4; + + // --- Events --- + event SetPlan(address plan); + event File(bytes32 indexed what, uint24 tin, uint24 tout); + event AddOperator(address indexed operator); + event RemoveOperator(address indexed operator); + + modifier onlyOperator { + require(operators[msg.sender] == 1, "D3MSwapPool/only-operator"); + _; + } + + constructor( + bytes32 _ilk, + address _hub, + address _dai, + address _gem, + address _plan + ) D3MSwapPool(_ilk, _hub, _dai, _gem) { + plan = ID3MPlan(_plan); + + // Initialize all fees to zero + feeData = FeeData({ + tin: uint24(BPS), + tout: uint24(BPS) + }); + } + + // --- Administration --- + + function setPlan(address _plan) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + + plan = ID3MPlan(_plan); + + emit SetPlan(_plan); + } + + function file(bytes32 what, uint24 _tin, uint24 _tout) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + // We need to restrict tin/tout combinations to be less than 100% to avoid arbitrage opportunities. + require(uint256(_tin) * uint256(_tout) <= BPS * BPS, "D3MSwapPool/invalid-fees"); + + if (what == "fees") { + feeData.tin = _tin; + feeData.tout = _tout; + } else revert("D3MSwapPool/file-unrecognized-param"); + + emit File(what, _tin, _tout); + } + + function addOperator(address operator) external auth { + operators[operator] = 1; + emit AddOperator(operator); + } + + function removeOperator(address operator) external auth { + operators[operator] = 0; + emit RemoveOperator(operator); + } + + // --- Pool Support --- + + function assetBalance() external view override returns (uint256) { + return dai.balanceOf(address(this)) + (gem.balanceOf(address(this)) + gemsWithdrawn) * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; + } + + // --- Getters --- + + function tin() external view returns (uint256) { + return feeData.tin; + } + + function tout() external view returns (uint256) { + return feeData.tout; + } + + // --- Swaps --- + + function previewSellGem(uint256 gemAmt) public view override returns (uint256 daiAmt) { + uint256 gemBalance = (gem.balanceOf(address(this)) + gemsWithdrawn) * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; + uint256 targetAssets = plan.getTargetAssets(ilk, gemBalance + dai.balanceOf(address(this))); + uint256 pipValue = uint256(sellGemPip.read()); + uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * pipValue / WAD; + require(gemBalance + gemValue <= targetAssets, "D3MSwapPool/gem-balance-too-high"); + FeeData memory _feeData = feeData; + daiAmt = gemValue * _feeData.tin / BPS; + } + + function previewBuyGem(uint256 daiAmt) public view override returns (uint256 gemAmt) { + uint256 gemBalance = (gem.balanceOf(address(this)) + gemsWithdrawn) * GEM_CONVERSION_FACTOR * uint256(pip.read()) / WAD; + uint256 targetAssets = plan.getTargetAssets(ilk, gemBalance + dai.balanceOf(address(this))); + FeeData memory _feeData = feeData; + uint256 gemValue = daiAmt * _feeData.tout / BPS; + require(targetAssets + gemValue <= gemBalance, "D3MSwapPool/gem-balance-too-low"); + uint256 pipValue = uint256(buyGemPip.read()); + gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * pipValue); + } + + // --- Whitelisted push/pull --- + + function pull(address to, uint256 amount) external onlyOperator { + gemsWithdrawn += amount; + require(gem.transfer(to, amount), "D3MSwapPool/failed-transfer"); + } + + function push(uint256 amount) external onlyOperator { + require(gem.transferFrom(msg.sender, address(this), amount), "D3MSwapPool/failed-transfer"); + if (gemsWithdrawn > amount) { + gemsWithdrawn -= amount; + } else { + gemsWithdrawn = 0; + } + } + +} From 65ef293ce59b27601b1c5256f29d313e84e396fc Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 11 Apr 2023 15:28:47 +0200 Subject: [PATCH 15/16] add linear fee swap pool; add expiry for PnP license --- README.md | 5 +- src/pools/D3MKinkedFeeSwapPool.sol | 2 +- src/pools/D3MLinearFeeSwapPool.sol | 121 +++++++++++++++++++++++++++ src/pools/D3MWhitelistedSwapPool.sol | 2 +- 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/pools/D3MLinearFeeSwapPool.sol diff --git a/README.md b/README.md index 7805e901..121f98f2 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ Any Comp that is accured can be permissionlessly collected into the pause proxy The following contracts are available under a Plug and Play license: -#### D3MSwapPool, D3MKinkedFeeSwapPool, D3MWhitelistedSwapPool +#### D3MSwapPool, D3MKinkedFeeSwapPool, D3MLinearFeeSwapPool, D3MWhitelistedSwapPool Owner: sparkprotocol.eth -Revenue Share: 10% of net earnings after any fees by SubDAO +Revenue Share: 10% of net earnings after any fees. +Expires: January 1st, 2026 at 00:00 UTC diff --git a/src/pools/D3MKinkedFeeSwapPool.sol b/src/pools/D3MKinkedFeeSwapPool.sol index 9054d942..4147a48c 100644 --- a/src/pools/D3MKinkedFeeSwapPool.sol +++ b/src/pools/D3MKinkedFeeSwapPool.sol @@ -66,7 +66,7 @@ contract D3MKinkedFeeSwapPool is D3MSwapPool { function file(bytes32 what, uint24 tin, uint24 tout) external auth { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); - // We need to restrict tin/tout combinations to be less than 100% to avoid arbitrage opportunities. + // We need to restrict tin/tout combinations to be less than 100% to avoid arbitragers able to endlessly take money require(uint256(tin) * uint256(tout) <= BPS * BPS, "D3MSwapPool/invalid-fees"); if (what == "fees1") { diff --git a/src/pools/D3MLinearFeeSwapPool.sol b/src/pools/D3MLinearFeeSwapPool.sol new file mode 100644 index 00000000..b7c690a1 --- /dev/null +++ b/src/pools/D3MLinearFeeSwapPool.sol @@ -0,0 +1,121 @@ +// 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 "./D3MSwapPool.sol"; + +/** + * @title D3M Linear Fee Swap Pool + * @notice Swap an asset for DAI. Linear bonding curve with fee1 at 0% gems and fee2 at 100% gems. + */ +contract D3MLinearFeeSwapPool is D3MSwapPool { + + struct FeeData { + uint24 tin1; // toll in at 0% gems [bps] + uint24 tout1; // toll out at 0% gems [bps] + uint24 tin2; // toll in at 100% gems [bps] + uint24 tout2; // toll out at 100% gems [bps] + } + + // --- Data --- + FeeData public feeData; + + uint256 constant internal BPS = 10 ** 4; + + // --- Events --- + event File(bytes32 indexed what, uint24 data); + event File(bytes32 indexed what, uint24 tin, uint24 tout); + + constructor(bytes32 _ilk, address _hub, address _dai, address _gem) D3MSwapPool(_ilk, _hub, _dai, _gem) { + // Initialize all fees to zero + feeData = FeeData({ + tin1: uint24(BPS), + tout1: uint24(BPS), + tin2: uint24(BPS), + tout2: uint24(BPS) + }); + } + + // --- Administration --- + + function file(bytes32 what, uint24 tin, uint24 tout) external auth { + require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); + // We need to restrict tin/tout combinations to be less than 100% to avoid arbitragers able to endlessly take money + require(uint256(tin) * uint256(tout) <= BPS * BPS, "D3MSwapPool/invalid-fees"); + + if (what == "fees1") { + feeData.tin1 = tin; + feeData.tout1 = tout; + } else if (what == "fees2") { + feeData.tin2 = tin; + feeData.tout2 = tout; + } else revert("D3MSwapPool/file-unrecognized-param"); + + emit File(what, tin, tout); + } + + // --- Getters --- + + function tin1() external view returns (uint256) { + return feeData.tin1; + } + + function tout1() external view returns (uint256) { + return feeData.tout1; + } + + function tin2() external view returns (uint256) { + return feeData.tin2; + } + + function tout2() external view returns (uint256) { + return feeData.tout2; + } + + // --- Swaps --- + + function previewSellGem(uint256 gemAmt) public view override returns (uint256 daiAmt) { + FeeData memory _feeData = feeData; + uint256 pipValue = uint256(sellGemPip.read()); + uint256 gemValue = gemAmt * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 daiBalance = dai.balanceOf(address(this)); + uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 fee = BPS; + uint256 totalBalance = daiBalance + gemBalance; + if (totalBalance > 0) { + // Please note the fee deduction is not included in the new total dai+gem balance to drastically simplify the calculation + fee = (_feeData.tin1 * daiBalance + _feeData.tin2 * gemBalance - (_feeData.tin1 + _feeData.tin2) * gemValue / 2) / totalBalance; + } + daiAmt = gemValue * fee / BPS; + } + + function previewBuyGem(uint256 daiAmt) public view override returns (uint256 gemAmt) { + FeeData memory _feeData = feeData; + uint256 pipValue = uint256(buyGemPip.read()); + uint256 daiBalance = dai.balanceOf(address(this)); + uint256 gemBalance = gem.balanceOf(address(this)) * GEM_CONVERSION_FACTOR * pipValue / WAD; + uint256 fee = BPS; + uint256 totalBalance = daiBalance + gemBalance; + if (totalBalance > 0) { + // Please note the fee deduction is not included in the new total dai+gem balance to drastically simplify the calculation + fee = (_feeData.tout1 * daiBalance + _feeData.tout2 * gemBalance - (_feeData.tout1 + _feeData.tout2) * daiAmt / 2) / totalBalance; + } + uint256 gemValue = daiAmt * fee / BPS; + gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * pipValue); + } + +} diff --git a/src/pools/D3MWhitelistedSwapPool.sol b/src/pools/D3MWhitelistedSwapPool.sol index dece507d..86dc3433 100644 --- a/src/pools/D3MWhitelistedSwapPool.sol +++ b/src/pools/D3MWhitelistedSwapPool.sol @@ -80,7 +80,7 @@ contract D3MWhitelistedSwapPool is D3MSwapPool { function file(bytes32 what, uint24 _tin, uint24 _tout) external auth { require(vat.live() == 1, "D3MSwapPool/no-file-during-shutdown"); - // We need to restrict tin/tout combinations to be less than 100% to avoid arbitrage opportunities. + // We need to restrict tin/tout combinations to be less than 100% to avoid arbitragers able to endlessly take money require(uint256(_tin) * uint256(_tout) <= BPS * BPS, "D3MSwapPool/invalid-fees"); if (what == "fees") { From 3a931e6b263a09e7f7c98f64dba3ef29d21e9cc8 Mon Sep 17 00:00:00 2001 From: Sam MacPherson Date: Tue, 11 Apr 2023 15:36:56 +0200 Subject: [PATCH 16/16] fix values --- src/pools/D3MLinearFeeSwapPool.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pools/D3MLinearFeeSwapPool.sol b/src/pools/D3MLinearFeeSwapPool.sol index b7c690a1..257377fc 100644 --- a/src/pools/D3MLinearFeeSwapPool.sol +++ b/src/pools/D3MLinearFeeSwapPool.sol @@ -112,7 +112,7 @@ contract D3MLinearFeeSwapPool is D3MSwapPool { uint256 totalBalance = daiBalance + gemBalance; if (totalBalance > 0) { // Please note the fee deduction is not included in the new total dai+gem balance to drastically simplify the calculation - fee = (_feeData.tout1 * daiBalance + _feeData.tout2 * gemBalance - (_feeData.tout1 + _feeData.tout2) * daiAmt / 2) / totalBalance; + fee = (_feeData.tout2 * daiBalance + _feeData.tout1 * gemBalance - (_feeData.tout1 + _feeData.tout2) * daiAmt / 2) / totalBalance; } uint256 gemValue = daiAmt * fee / BPS; gemAmt = gemValue * WAD / (GEM_CONVERSION_FACTOR * pipValue);