diff --git a/.gitmodules b/.gitmodules index 5963803..b3aa01c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "lib/uniswapx"] path = lib/uniswapx url = https://github.com/uniswap/uniswapx +[submodule "lib/index-coop-smart-contracts"] + path = lib/index-coop-smart-contracts + url = https://github.com/indexcoop/index-coop-smart-contracts diff --git a/lib/index-coop-smart-contracts b/lib/index-coop-smart-contracts new file mode 160000 index 0000000..31b483a --- /dev/null +++ b/lib/index-coop-smart-contracts @@ -0,0 +1 @@ +Subproject commit 31b483a02831d6732599921c1dcf5f063bf06078 diff --git a/src/interfaces/IFlashMintDexV5.sol b/src/interfaces/IFlashMintDexV5.sol new file mode 100644 index 0000000..0b2ad82 --- /dev/null +++ b/src/interfaces/IFlashMintDexV5.sol @@ -0,0 +1,107 @@ +pragma solidity ^0.8.0; + +import { ISetToken } from "./ISetToken.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IFlashMintDexV5 { + /* ============ Structs ============ */ + + struct LeveragedTokenData { + address collateralAToken; + address collateralToken; + uint256 collateralAmount; + address debtToken; + uint256 debtAmount; + } + + struct SwapData { + address[] path; + uint24[] fees; + int24[] tickSpacing; + address pool; // For Curve swaps + bytes32[] poolIds; // For Balancer V2 multihop swaps + uint8 exchange; + } + + /* ============ Events ============ */ + + event FlashMint( + address indexed _recipient, // The recipient address of the issued SetTokens + ISetToken indexed _setToken, // The issued SetToken + address indexed _inputToken, // The address of the input asset(ERC20/ETH) used to issue the SetTokens + uint256 _amountInputToken, // The amount of input tokens used for issuance + uint256 _amountSetIssued // The amount of SetTokens received by the recipient + ); + + event FlashRedeem( + address indexed _recipient, // The recipient address which redeemed the SetTokens + ISetToken indexed _setToken, // The redeemed SetToken + address indexed _outputToken, // The address of output asset(ERC20/ETH) received by the recipient + uint256 _amountSetRedeemed, // The amount of SetTokens redeemed for output tokens + uint256 _amountOutputToken // The amount of output tokens received by the recipient + ); + + /* ============ Functions ============ */ + + function getLeveragedTokenData( + ISetToken _setToken, + uint256 _setAmount, + bool _isIssuance + ) external view returns (LeveragedTokenData memory); + + function approveToken(IERC20 _token) external; + + function getIssueExactSet( + address _setToken, + uint256 _setAmount, + uint256 _maxAmountInputToken, + SwapData memory _swapDataDebtForCollateral, + SwapData memory _swapDataInputToken + ) external returns (uint256); + + function getRedeemExactSet( + ISetToken _setToken, + uint256 _setAmount, + SwapData memory _swapDataCollateralForDebt, + SwapData memory _swapDataOutputToken + ) external returns (uint256); + + function redeemExactSetForETH( + ISetToken _setToken, + uint256 _setAmount, + uint256 _minAmountOutputToken, + SwapData memory _swapDataCollateralForDebt, + SwapData memory _swapDataOutputToken + ) external; + + function redeemExactSetForERC20( + ISetToken _setToken, + uint256 _setAmount, + address _outputToken, + uint256 _minAmountOutputToken, + SwapData memory _swapDataCollateralForDebt, + SwapData memory _swapDataOutputToken + ) external; + + function issueExactSetFromERC20( + ISetToken _setToken, + uint256 _setAmount, + address _inputToken, + uint256 _maxAmountInputToken, + SwapData memory _swapDataDebtForCollateral, + SwapData memory _swapDataInputToken + ) external; + + function issueExactSetFromETH( + ISetToken _setToken, + uint256 _setAmount, + SwapData memory _swapDataDebtForCollateral, + SwapData memory _swapDataInputToken + ) external payable; + + function onMorphoFlashLoan(uint256 assets, bytes calldata data) external; + + function approveTokens(IERC20[] memory _tokens) external; + + function approveSetToken(ISetToken _setToken) external; +} \ No newline at end of file diff --git a/src/uniswapx/FlashMintExecutor.sol b/src/uniswapx/FlashMintExecutor.sol new file mode 100644 index 0000000..78ea454 --- /dev/null +++ b/src/uniswapx/FlashMintExecutor.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {Owned} from "solmate/src/auth/Owned.sol"; +import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol"; +import {ERC20} from "solmate/src/tokens/ERC20.sol"; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +import {IReactorCallback} from "uniswapx/src/interfaces/IReactorCallback.sol"; +import {IReactor} from "uniswapx/src/interfaces/IReactor.sol"; +import {ResolvedOrder, SignedOrder} from "uniswapx/src/base/ReactorStructs.sol"; + +import {IFlashMintDexV5} from "../interfaces/IFlashMintDexV5.sol"; +import {ISetToken} from "../interfaces/ISetToken.sol"; + +/** + * @title FlashMintExecutor + * @notice A UniswapX executor that routes fills to a FlashMint contract using ERC20 tokens only. + * + * The owner can register enabled FlashMint tokens (typically SetTokens) and for each its + * corresponding FlashMint contract. In the reactorCallback, if issuance (or redemption) + * is requested, the contract verifies that the SetToken is enabled and then calls the appropriate + * FlashMint function. + * + * The callbackData passed into reactorCallback must be ABI-encoded as: + * + * abi.encode( + * address[] tokensToApproveForFlashMint, // Tokens to approve for the FlashMint contract + * address[] tokensToApproveForReactor, // Tokens to approve for the reactor + * ISetToken setToken, // The SetToken to issue or redeem + * uint256 setAmount, // The SetToken amount to issue/redeem + * address inputOutputToken, // The ERC20 token used as input (issuance) or desired output (redemption) + * uint256 inputOutputTokenAmount, // The max input (issuance) or min output (redemption) amount + * IFlashMintDexV5.SwapData swapDataCollateral, // Swap data for collateral + * IFlashMintDexV5.SwapData swapDataInputOutputToken, // Swap data for the input/output token + * bool isIssuance // True for issuance, false for redemption + * ) + */ +contract FlashMintExecutor is IReactorCallback, Owned { + using SafeTransferLib for ERC20; + + event FlashMintTokenAdded(address indexed token, address indexed flashMintContract); + event FlashMintTokenRemoved(address indexed token); + + /// @notice Reverts if reactorCallback is called by an address other than the expected reactor. + error MsgSenderNotReactor(); + + IReactor public immutable reactor; + + // Mapping of enabled FlashMint tokens (typically SetTokens) to a boolean flag. + mapping(address => bool) public flashMintEnabled; + // Mapping from an enabled FlashMint token (SetToken) to its FlashMint contract. + mapping(address => IFlashMintDexV5) public flashMintForToken; + + modifier onlyReactor() { + if (msg.sender != address(reactor)) { + revert MsgSenderNotReactor(); + } + _; + } + + constructor( + IReactor _reactor, + address _owner + ) Owned(_owner) { + reactor = _reactor; + } + + /// @notice Enables a token and registers its FlashMint contract. + /// @param token The token (e.g. a SetToken) to enable. + /// @param flashMintContract The FlashMint contract to use for this token. + function addFlashMintToken(address token, IFlashMintDexV5 flashMintContract) external onlyOwner { + require(token != address(0), "Invalid token"); + require(address(flashMintContract) != address(0), "Invalid FlashMint contract"); + flashMintEnabled[token] = true; + flashMintForToken[token] = flashMintContract; + emit FlashMintTokenAdded(token, address(flashMintContract)); + } + + /// @notice Removes a token from the enabled list. + /// @param token The token to remove. + function removeFlashMintToken(address token) external onlyOwner { + flashMintEnabled[token] = false; + delete flashMintForToken[token]; + emit FlashMintTokenRemoved(token); + } + + /// @notice Withdraws a token from the contract. + /// @param _token The token to withdraw. + /// @param _amount The amount to withdraw. + function withdrawToken(IERC20 _token, uint256 _amount) external onlyOwner { + _token.transfer(msg.sender, _amount); + } + + /// @notice Forwards a single order to the reactor with callback. + function execute(SignedOrder calldata order, bytes calldata callbackData) external { + reactor.executeWithCallback(order, callbackData); + } + + /// @notice Forwards a batch of orders to the reactor with callback. + function executeBatch(SignedOrder[] calldata orders, bytes calldata callbackData) external { + reactor.executeBatchWithCallback(orders, callbackData); + } + + /** + * @notice UniswapX reactor callback that fills orders via the appropriate FlashMint contract. + * + * The callbackData is ABI-decoded as follows: + * - address[] tokensToApproveForFlashMint: tokens to approve for the FlashMint contract. + * - address[] tokensToApproveForReactor: tokens to approve for the reactor. + * - ISetToken setToken: the SetToken to issue or redeem. + * - uint256 setAmount: the SetToken amount to issue/redeem. + * - address inputOutputToken: the ERC20 token used as input (issuance) or desired output (redemption). + * - uint256 inputOutputTokenAmount: the max input (issuance) or min output (redemption) amount. + * - IFlashMintDexV5.SwapData swapDataCollateral: swap data for collateral. + * - IFlashMintDexV5.SwapData swapDataInputOutputToken: swap data for the input/output token. + * - bool isIssuance: true for issuance, false for redemption. + */ + function reactorCallback( + ResolvedOrder[] calldata, + bytes calldata callbackData + ) external override onlyReactor { + ( + address[] memory tokensToApproveForFlashMint, + address[] memory tokensToApproveForReactor, + ISetToken setToken, + uint256 setAmount, + address inputOutputToken, + uint256 inputOutputTokenAmount, + IFlashMintDexV5.SwapData memory swapDataCollateral, + IFlashMintDexV5.SwapData memory swapDataInputOutputToken, + bool isIssuance + ) = abi.decode( + callbackData, + ( + address[], + address[], + ISetToken, + uint256, + address, + uint256, + IFlashMintDexV5.SwapData, + IFlashMintDexV5.SwapData, + bool + ) + ); + + IFlashMintDexV5 flashMintContract = getFlashMintContract(setToken); + unchecked { + for (uint256 i = 0; i < tokensToApproveForFlashMint.length; i++) { + ERC20(tokensToApproveForFlashMint[i]).safeApprove(address(flashMintContract), type(uint256).max); + } + for (uint256 i = 0; i < tokensToApproveForReactor.length; i++) { + ERC20(tokensToApproveForReactor[i]).safeApprove(address(reactor), type(uint256).max); + } + } + + if (isIssuance) { + require(flashMintEnabled[address(setToken)], "FlashMint not enabled for issuance"); + flashMintContract.issueExactSetFromERC20( + setToken, + setAmount, + inputOutputToken, + inputOutputTokenAmount, + swapDataCollateral, + swapDataInputOutputToken + ); + } else { + require(flashMintEnabled[address(setToken)], "FlashMint not enabled for redemption"); + flashMintContract.redeemExactSetForERC20( + setToken, + setAmount, + inputOutputToken, + inputOutputTokenAmount, + swapDataCollateral, + swapDataInputOutputToken + ); + } + } + + function getFlashMintContract(ISetToken setToken) internal view returns (IFlashMintDexV5) { + IFlashMintDexV5 flashMintContract = flashMintForToken[address(setToken)]; + require(address(flashMintContract) != address(0), "No flashMint contract for this token"); + return flashMintContract; + } +} diff --git a/test/uniswapx/FlashMintExecutor.t.sol b/test/uniswapx/FlashMintExecutor.t.sol new file mode 100644 index 0000000..2d20d5c --- /dev/null +++ b/test/uniswapx/FlashMintExecutor.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "forge-std/Vm.sol"; +import "../../src/uniswapx/FlashMintExecutor.sol"; +import {IReactor} from "uniswapx/src/interfaces/IReactor.sol"; +import "../../src/interfaces/IFlashMintDexV5.sol"; + +contract FlashMintExecutorTest is Test { + FlashMintExecutor flashMintExecutor; + + address public owner; + address public mockReactor; + address public mockSetToken; + address public mockFlashMint; + address public nonOwner; + + event FlashMintTokenAdded(address indexed token, address indexed flashMintContract); + event FlashMintTokenRemoved(address indexed token); + + function setUp() public { + owner = msg.sender; + + mockReactor = address(0x1); + mockSetToken = address(0x2); + mockFlashMint = address(0x3); + nonOwner = address(0x4); + + flashMintExecutor = new FlashMintExecutor( + IReactor(mockReactor), + owner + ); + } + + function testConstructor() public { + assertEq(address(flashMintExecutor.reactor()), mockReactor, "Incorrect reactor address"); + assertEq(flashMintExecutor.owner(), owner, "Incorrect owner address"); + } + + function testAddFlashMintToken() public { + vm.expectEmit(true, false, false, true); + emit FlashMintTokenAdded(mockSetToken, mockFlashMint); + + vm.prank(owner); + flashMintExecutor.addFlashMintToken(mockSetToken, IFlashMintDexV5(mockFlashMint)); + + assertTrue(flashMintExecutor.flashMintEnabled(mockSetToken), "Token should be enabled"); + assertEq( + address(flashMintExecutor.flashMintForToken(mockSetToken)), + mockFlashMint, + "Incorrect flash mint contract" + ); + } + + function testCannotAddFlashMintTokenWithZeroAddresses() public { + vm.expectRevert("Invalid token"); + vm.prank(owner); + flashMintExecutor.addFlashMintToken(address(0), IFlashMintDexV5(mockFlashMint)); + + vm.expectRevert("Invalid FlashMint contract"); + vm.prank(owner); + flashMintExecutor.addFlashMintToken(mockSetToken, IFlashMintDexV5(address(0))); + } + + function testCannotAddFlashMintTokenIfNotOwner() public { + vm.prank(nonOwner); + vm.expectRevert("UNAUTHORIZED"); + flashMintExecutor.addFlashMintToken(mockSetToken, IFlashMintDexV5(mockFlashMint)); + } + + function testRemoveFlashMintToken() public { + vm.prank(owner); + flashMintExecutor.addFlashMintToken(mockSetToken, IFlashMintDexV5(mockFlashMint)); + + vm.expectEmit(true, false, false, true); + emit FlashMintTokenRemoved(mockSetToken); + + vm.prank(owner); + flashMintExecutor.removeFlashMintToken(mockSetToken); + + assertFalse(flashMintExecutor.flashMintEnabled(mockSetToken), "Token should be disabled"); + assertEq( + address(flashMintExecutor.flashMintForToken(mockSetToken)), + address(0), + "Flash mint contract should be removed" + ); + } + + function testCannotRemoveFlashMintTokenIfNotOwner() public { + vm.prank(owner); + flashMintExecutor.addFlashMintToken(mockSetToken, IFlashMintDexV5(mockFlashMint)); + + vm.prank(nonOwner); + vm.expectRevert("UNAUTHORIZED"); + flashMintExecutor.removeFlashMintToken(mockSetToken); + } + + function testUpdateFlashMintToken() public { + address newMockFlashMint = address(0x99); + + vm.prank(owner); + flashMintExecutor.addFlashMintToken(mockSetToken, IFlashMintDexV5(mockFlashMint)); + + vm.expectEmit(true, true, false, false); + emit FlashMintTokenAdded(mockSetToken, newMockFlashMint); + + vm.prank(owner); + flashMintExecutor.addFlashMintToken(mockSetToken, IFlashMintDexV5(newMockFlashMint)); + + assertTrue(flashMintExecutor.flashMintEnabled(mockSetToken), "Token should still be enabled"); + assertEq( + address(flashMintExecutor.flashMintForToken(mockSetToken)), + newMockFlashMint, + "Flash mint contract should be updated" + ); + } +}