diff --git a/src/funnels/callees/SwapperCalleePsm.sol b/src/funnels/callees/SwapperCalleePsm.sol new file mode 100644 index 00000000..a6956fc2 --- /dev/null +++ b/src/funnels/callees/SwapperCalleePsm.sol @@ -0,0 +1,71 @@ +// 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.16; + +interface GemLike { + function approve(address, uint256) external; + function decimals() external view returns (uint8); +} + +interface PsmLike { + function sellGemNoFee(address, uint256) external returns (uint256); + function buyGemNoFee(address, uint256) external returns (uint256); + function dai() external returns (address); + function gem() external returns (address); +} + +contract SwapperCalleePsm { + mapping (address => uint256) public wards; + + address public immutable psm; + address public immutable gem; + uint256 public immutable to18ConversionFactor; + + event Rely(address indexed usr); + event Deny(address indexed usr); + + constructor(address _psm) { + psm = _psm; + gem = PsmLike(psm).gem(); + GemLike(PsmLike(psm).dai()).approve(address(psm), type(uint256).max); + GemLike(gem).approve(address(psm), type(uint256).max); + to18ConversionFactor = 10 ** (18 - GemLike(gem).decimals()); + + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + modifier auth() { + require(wards[msg.sender] == 1, "SwapperCalleePsm/not-authorized"); + _; + } + + function rely(address usr) external auth { + wards[usr] = 1; + emit Rely(usr); + } + + function deny(address usr) external auth { + wards[usr] = 0; + emit Deny(usr); + } + + function swapCallback(address src, address /* dst */, uint256 amt, uint256 /* minOut */, address to, bytes calldata /* data */) external auth { + if (src == gem) PsmLike(psm).sellGemNoFee(to, amt); + else PsmLike(psm).buyGemNoFee (to, amt / to18ConversionFactor); + } +} diff --git a/test/funnels/Swapper.t.sol b/test/funnels/Swapper.t.sol index 20aac03c..22a0c487 100644 --- a/test/funnels/Swapper.t.sol +++ b/test/funnels/Swapper.t.sol @@ -5,8 +5,10 @@ pragma solidity ^0.8.16; import "dss-test/DssTest.sol"; import { Swapper } from "src/funnels/Swapper.sol"; import { SwapperCalleeUniV3 } from "src/funnels/callees/SwapperCalleeUniV3.sol"; +import { SwapperCalleePsm } from "src/funnels/callees/SwapperCalleePsm.sol"; import { AllocatorRoles } from "src/AllocatorRoles.sol"; import { AllocatorBuffer } from "src/AllocatorBuffer.sol"; +import { PsmMock } from "test/mocks/PsmMock.sol"; interface GemLike { function balanceOf(address) external view returns (uint256); @@ -172,6 +174,46 @@ contract SwapperTest is DssTest { assertEq(end, initialTime + 7200); } + function testSwapPsmCallee() public { + PsmMock psm = new PsmMock(DAI, USDC); + SwapperCalleePsm swapperCalleePsm = new SwapperCalleePsm(address(psm)); + psm.rely(address(swapperCalleePsm)); + swapperCalleePsm.rely(address(swapper)); + deal(DAI, address(psm), 1_000 * WAD, true); + + uint256 prevSrc = GemLike(USDC).balanceOf(address(buffer)); + uint256 prevDst = GemLike(DAI).balanceOf(address(buffer)); + + vm.expectEmit(true, true, true, true); + emit Swap(FACILITATOR, USDC, DAI, 1_000 * 10**6, 1_000 * WAD); + vm.prank(FACILITATOR); uint256 out = swapper.swap(USDC, DAI, 1_000 * 10**6, 1_000 * WAD, address(swapperCalleePsm), ""); + + assertEq(out, 1_000 * WAD); + assertEq(GemLike(USDC).balanceOf(address(buffer)), prevSrc - 1_000 * 10**6); + assertEq(GemLike(DAI).balanceOf(address(buffer)), prevDst + 1_000 * WAD); + assertEq(GemLike(DAI).balanceOf(address(swapper)), 0); + assertEq(GemLike(USDC).balanceOf(address(swapper)), 0); + assertEq(GemLike(DAI).balanceOf(address(swapperCalleePsm)), 0); + assertEq(GemLike(USDC).balanceOf(address(swapperCalleePsm)), 0); + + vm.warp(uint32(block.timestamp) + 3600); + + prevSrc = GemLike(DAI).balanceOf(address(buffer)); + prevDst = GemLike(USDC).balanceOf(address(buffer)); + + vm.expectEmit(true, true, true, false); + emit Swap(FACILITATOR, DAI, USDC, 1_000 * WAD, 1_000 * 10**6); + vm.prank(FACILITATOR); out = swapper.swap(DAI, USDC, 1_000 * WAD, 1_000 * 10**6, address(swapperCalleePsm), ""); + + assertEq(out, 1_000 * 10**6); + assertEq(GemLike(DAI).balanceOf(address(buffer)), prevSrc - 1_000 * WAD); + assertEq(GemLike(USDC).balanceOf(address(buffer)), prevDst + 1_000 * 10**6); + assertEq(GemLike(DAI).balanceOf(address(swapper)), 0); + assertEq(GemLike(USDC).balanceOf(address(swapper)), 0); + assertEq(GemLike(DAI).balanceOf(address(swapperCalleePsm)), 0); + assertEq(GemLike(USDC).balanceOf(address(swapperCalleePsm)), 0); + } + function testSwapAllAferEra() public { vm.prank(FACILITATOR); swapper.swap(USDC, DAI, 10_000 * 10**6, 9900 * WAD, address(uniV3Callee), USDC_DAI_PATH); (, uint64 era,,) = swapper.limits(USDC, DAI); diff --git a/test/funnels/callees/SwapperCalleePsm.t.sol b/test/funnels/callees/SwapperCalleePsm.t.sol new file mode 100644 index 00000000..f96bcc3c --- /dev/null +++ b/test/funnels/callees/SwapperCalleePsm.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.16; + +import "dss-test/DssTest.sol"; +import { SwapperCalleePsm } from "src/funnels/callees/SwapperCalleePsm.sol"; +import { PsmMock } from "test/mocks/PsmMock.sol"; + +interface GemLike { + function balanceOf(address) external view returns (uint256); + function transfer(address, uint256) external; + function decimals() external view returns (uint8); +} + +contract SwapperCalleePsmTest is DssTest { + + PsmMock psm; + PsmMock psmUSDT; + SwapperCalleePsm callee; + SwapperCalleePsm calleeUSDT; + + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + + function setUp() public { + vm.createSelectFork(vm.envString("ETH_RPC_URL")); + + psm = new PsmMock(DAI, USDC); + callee = new SwapperCalleePsm(address(psm)); + psm.rely(address(callee)); + callee.rely(address(this)); + + psmUSDT = new PsmMock(DAI, USDT); + calleeUSDT = new SwapperCalleePsm(address(psmUSDT)); + psmUSDT.rely(address(calleeUSDT)); + calleeUSDT.rely(address(this)); + + deal(DAI, address(this), 1_000_000 * WAD, true); + deal(DAI, address(psm), 1_000_000 * WAD, true); + deal(DAI, address(psmUSDT), 1_000_000 * WAD, true); + deal(USDC, address(this), 1_000_000 * 10**6, true); + deal(USDC, psm.pocket(), 1_000_000 * 10**6, true); + deal(USDT, address(this), 1_000_000 * 10**6, true); + deal(USDT, psmUSDT.pocket(), 1_000_000 * 10**6, true); + } + + function testConstructor() public { + SwapperCalleePsm c = new SwapperCalleePsm(address(psm)); + assertEq(c.psm(), address(psm)); + assertEq(c.gem(), USDC); + assertEq(c.to18ConversionFactor(), 10**12); + assertEq(c.wards(address(this)), 1); + } + + function testAuth() public { + checkAuth(address(callee), "SwapperCalleePsm"); + } + + function testModifiers() public { + bytes4[] memory authedMethods = new bytes4[](1); + authedMethods[0] = callee.swapCallback.selector; + + vm.startPrank(address(0xBEEF)); + checkModifier(address(callee), "SwapperCalleePsm/not-authorized", authedMethods); + vm.stopPrank(); + } + + function checkPsmSwap(SwapperCalleePsm callee_, address from, address to) public { + uint256 prevFrom = GemLike(from).balanceOf(address(this)); + uint256 prevTo = GemLike(to).balanceOf(address(this)); + uint8 fromDecimals = GemLike(from).decimals(); + uint8 toDecimals = GemLike(to).decimals(); + + GemLike(from).transfer(address(callee_), 10_000 * 10**fromDecimals); + callee_.swapCallback(from, to, 10_000 * 10**fromDecimals, 0, address(this), ""); + + assertEq(GemLike(from).balanceOf(address(this)), prevFrom - 10_000 * 10**fromDecimals); + assertEq(GemLike(to ).balanceOf(address(this)), prevTo + 10_000 * 10**toDecimals ); + assertEq(GemLike(from).balanceOf(address(callee_)), 0); + assertEq(GemLike(to ).balanceOf(address(callee_)), 0); + } + + function testDaiToGemSwap() public { + checkPsmSwap(callee, DAI, USDC); + checkPsmSwap(calleeUSDT, DAI, USDT); + } + + function testGemToDaiSwap() public { + checkPsmSwap(callee, USDC, DAI); + checkPsmSwap(calleeUSDT, DAI, USDT); + } +} diff --git a/test/mocks/PsmMock.sol b/test/mocks/PsmMock.sol new file mode 100644 index 00000000..bf3401be --- /dev/null +++ b/test/mocks/PsmMock.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.16; + +interface GemLike { + function approve(address, uint256) external; + function transfer(address, uint256) external; + function transferFrom(address, address, uint256) external; + function decimals() external view returns (uint8); +} + +contract PsmMock { + mapping(address => uint256) public wards; + + address public immutable dai; + address public immutable gem; + uint256 public immutable to18ConversionFactor; + + event Rely(address indexed usr); + event Deny(address indexed usr); + event SellGem(address indexed owner, uint256 value, uint256 fee); + event BuyGem(address indexed owner, uint256 value, uint256 fee); + + modifier auth() { + require(wards[msg.sender] == 1, "PsmMock/not-authorized"); + _; + } + + constructor(address dai_, address gem_) { + dai = dai_; + gem = gem_; + to18ConversionFactor = 10**(18 - GemLike(gem_).decimals()); + + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + 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 pocket() external view returns (address) { + return address(this); + } + + function sellGemNoFee(address usr, uint256 gemAmt) external auth returns (uint256 daiOutWad) { + daiOutWad = gemAmt * to18ConversionFactor; + + GemLike(gem).transferFrom(msg.sender, address(this), gemAmt); + GemLike(dai).transfer(usr, daiOutWad); + + emit SellGem(usr, gemAmt, 0); + } + + function buyGemNoFee(address usr, uint256 gemAmt) external auth returns (uint256 daiInWad) { + daiInWad = gemAmt * to18ConversionFactor; + + GemLike(dai).transferFrom(msg.sender, address(this), daiInWad); + GemLike(gem).transfer(usr, gemAmt); + + emit BuyGem(usr, gemAmt, 0); + } +}