diff --git a/contracts/BaseRouter.sol b/contracts/BaseRouter.sol index 1c9755d..6ca42e6 100644 --- a/contracts/BaseRouter.sol +++ b/contracts/BaseRouter.sol @@ -12,6 +12,7 @@ import "./interfaces/external/IWETH9.sol"; import "./interfaces/ICoreBorrow.sol"; import "./interfaces/ILiquidityGauge.sol"; import "./interfaces/ISavingsRateIlliquid.sol"; +import "./interfaces/ISwapper.sol"; import "./interfaces/IVaultManager.sol"; // ============================== STRUCTS AND ENUM ============================= @@ -28,6 +29,7 @@ enum ActionType { claimRewards, gaugeDeposit, borrower, + swapper, mintSavingsRate, depositSavingsRate, redeemSavingsRate, @@ -198,6 +200,17 @@ abstract contract BaseRouter is Initializable { _changeAllowance(IERC20(collateral), address(vaultManager), type(uint256).max); _angleBorrower(vaultManager, actionsBorrow, dataBorrow, to, who, repayData); _changeAllowance(IERC20(collateral), address(vaultManager), 0); + } else if (actions[i] == ActionType.swapper) { + ( + ISwapper swapperContract, + IERC20 inToken, + IERC20 outToken, + address outTokenRecipient, + uint256 outTokenOwed, + uint256 inTokenObtained, + bytes memory payload + ) = abi.decode(data[i], (ISwapper, IERC20, IERC20, address, uint256, uint256, bytes)); + _swapper(swapperContract, inToken, outToken, outTokenRecipient, outTokenOwed, inTokenObtained, payload); } else if (actions[i] == ActionType.mintSavingsRate) { (IERC20 token, IERC4626 savingsRate, uint256 shares, address to, uint256 maxAmountIn) = abi.decode( data[i], @@ -373,6 +386,26 @@ abstract contract BaseRouter is Initializable { } } + /// @notice Uses an external swapper + /// @param swapper Contracts implementing the logic of the swap + /// @param inToken Token used to do the swap + /// @param outToken Token wanted + /// @param outTokenRecipient Address who should have at the end of the swap at least `outTokenOwed` + /// @param outTokenOwed Minimal amount for the `outTokenRecipient` + /// @param inTokenObtained Amount of `inToken` used for the swap + /// @param data Additional info for the specific swapper + function _swapper( + ISwapper swapper, + IERC20 inToken, + IERC20 outToken, + address outTokenRecipient, + uint256 outTokenOwed, + uint256 inTokenObtained, + bytes memory data + ) internal { + swapper.swap(inToken, outToken, outTokenRecipient, outTokenOwed, inTokenObtained, data); + } + /// @notice Allows to swap between tokens via UniswapV3 (if there is a path) /// @param inToken Token used as entrance of the swap /// @param amount Amount of in token to swap diff --git a/contracts/interfaces/ISwapper.sol b/contracts/interfaces/ISwapper.sol new file mode 100644 index 0000000..aa21ad2 --- /dev/null +++ b/contracts/interfaces/ISwapper.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/interfaces/IERC20.sol"; + +/// @title ISwapper +/// @author Angle Core Team +/// @notice Interface for a generic swapper, that supports swaps of higher complexity than aggregators +interface ISwapper { + function swap( + IERC20 inToken, + IERC20 outToken, + address outTokenRecipient, + uint256 outTokenOwed, + uint256 inTokenObtained, + bytes memory data + ) external; +} diff --git a/contracts/mock/MockSwapper.sol b/contracts/mock/MockSwapper.sol new file mode 100644 index 0000000..ac17ffb --- /dev/null +++ b/contracts/mock/MockSwapper.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.17; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract MockSwapper { + using SafeERC20 for IERC20; + + function swap( + IERC20 inToken, + IERC20 outToken, + address outTokenRecipient, + uint256 outTokenOwed, + uint256 inTokenObtained, + bytes memory + ) external { + inToken.safeTransferFrom(msg.sender, address(this), inTokenObtained); + outToken.safeTransfer(outTokenRecipient, outTokenOwed); + } +} diff --git a/package.json b/package.json index b5cfd60..68c5cc7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "foundry:setup": "curl -L https://foundry.paradigm.xyz | bash && foundryup && git submodule update --init --recursive", "deploy": "hardhat deploy --network", "etherscan": "hardhat etherscan-verify --network", + "foundry:test": "forge test -vvvv", + "hardhat:compile": "hardhat compile", + "hardhat:test": "hardhat test", "license": "hardhat prepend-spdx-license", "lint": "yarn lint:sol && yarn lint:js:fix", "lint:js:fix": "eslint --ignore-path .gitignore --fix --max-warnings 30 'test/**/*.{js,ts}' '*.{js,ts}'", diff --git a/test/hardhat/router/baseRouter.test.ts b/test/hardhat/router/baseRouter.test.ts index 270091f..09001de 100644 --- a/test/hardhat/router/baseRouter.test.ts +++ b/test/hardhat/router/baseRouter.test.ts @@ -16,6 +16,8 @@ import { MockLiquidityGauge__factory, MockRouterSidechain, MockRouterSidechain__factory, + MockSwapper, + MockSwapper__factory, MockTokenPermit, MockTokenPermit__factory, MockUniswapV3Router, @@ -37,6 +39,7 @@ contract('BaseRouter', () => { let uniswap: MockUniswapV3Router; let oneInch: Mock1Inch; let router: MockRouterSidechain; + let swapper: MockSwapper; let UNIT_USDC: BigNumber; let USDCdecimal: BigNumber; let governor: string; @@ -81,6 +84,7 @@ contract('BaseRouter', () => { await core.toggleGovernor(governor); await core.toggleGuardian(governor); await core.toggleGuardian(guardian); + swapper = (await new MockSwapper__factory(deployer).deploy()) as MockSwapper; await router.initializeRouter(core.address, uniswap.address, oneInch.address); }); describe('initializeRouter', () => { @@ -453,6 +457,43 @@ contract('BaseRouter', () => { await router.connect(alice).mixer(permits, actions, dataMixer); }); }); + describe('swapper', () => { + it('reverts - when not enough balance', async () => { + const actions = [ActionType.swapper]; + const swapperData = ethers.utils.defaultAbiCoder.encode( + ['address', 'address', 'address', 'address', 'uint256', 'uint256', 'bytes'], + [swapper.address, USDC.address, agEUR.address, bob.address, parseEther('1'), parseUnits('1', 6), '0x'], + ); + const dataMixer = [swapperData]; + await expect(router.connect(alice).mixer(permits, actions, dataMixer)).to.be.reverted; + await router + .connect(impersonatedSigners[governor]) + .changeAllowance([USDC.address], [swapper.address], [MAX_UINT256]); + await expect(router.connect(alice).mixer(permits, actions, dataMixer)).to.be.reverted; + }); + it('success - when enough balance', async () => { + await USDC.mint(alice.address, parseUnits('1', USDCdecimal)); + await USDC.connect(alice).approve(router.address, parseUnits('1', USDCdecimal)); + await agEUR.mint(swapper.address, parseEther('1')); + await router + .connect(impersonatedSigners[governor]) + .changeAllowance([USDC.address], [swapper.address], [MAX_UINT256]); + const transferData = ethers.utils.defaultAbiCoder.encode( + ['address', 'address', 'uint256'], + [USDC.address, router.address, parseUnits('1', USDCdecimal)], + ); + + const swapperData = ethers.utils.defaultAbiCoder.encode( + ['address', 'address', 'address', 'address', 'uint256', 'uint256', 'bytes'], + [swapper.address, USDC.address, agEUR.address, bob.address, parseEther('1'), parseUnits('1', 6), '0x'], + ); + const actions = [ActionType.transfer, ActionType.swapper]; + const dataMixer = [transferData, swapperData]; + await router.connect(alice).mixer(permits, actions, dataMixer); + expect(await USDC.balanceOf(swapper.address)).to.be.equal(parseUnits('1', 6)); + expect(await agEUR.balanceOf(bob.address)).to.be.equal(parseEther('1')); + }); + }); describe('claimRewards', () => { it('success - claiming rewards from a gauge', async () => { const gauge = (await new MockLiquidityGauge__factory(deployer).deploy(USDC.address)) as MockLiquidityGauge; diff --git a/utils/helpers.ts b/utils/helpers.ts index 50e4450..9bf7ef7 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -80,6 +80,7 @@ export enum ActionType { claimRewards, gaugeDeposit, borrower, + swapper, mintSavingsRate, depositSavingsRate, redeemSavingsRate,