diff --git a/.gitmodules b/.gitmodules index d9919b8..f87cde7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,6 @@ [submodule "foundry/lib/solady"] path = foundry/lib/solady url = https://github.com/vectorized/solady +[submodule "lib/balancer-v3-monorepo"] + path = lib/balancer-v3-monorepo + url = https://github.com/balancer/balancer-v3-monorepo diff --git a/foundry/remappings.txt b/foundry/remappings.txt index d10f28f..6e43320 100644 --- a/foundry/remappings.txt +++ b/foundry/remappings.txt @@ -4,6 +4,7 @@ @src/=src/ @uniswap-v2/=lib/v2-core/ @balancer-labs/v2-interfaces=lib/balancer-v2-monorepo/pkg/interfaces +@balancer-labs/v3-interfaces=lib/balancer-v3-monorepo/pkg/interfaces @uniswap/v3-updated/=lib/v3-updated/ @uniswap/v3-core/=lib/v3-core/ @uniswap/v4-core/=lib/v4-core/ diff --git a/foundry/src/executors/BalancerV3Executor.sol b/foundry/src/executors/BalancerV3Executor.sol new file mode 100644 index 0000000..8083ca5 --- /dev/null +++ b/foundry/src/executors/BalancerV3Executor.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "@interfaces/IExecutor.sol"; +import { + IERC20, + SafeERC20 +} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IVault} from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { + SwapKind, + VaultSwapParams +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; +import {RestrictTransferFrom} from "../RestrictTransferFrom.sol"; + +error BalancerV3Executor__InvalidDataLength(); +error BalancerV3Executor__SenderIsNotVault(address sender); + +contract BalancerV3Executor is IExecutor, RestrictTransferFrom { + using SafeERC20 for IERC20; + + IVault private constant VAULT = + IVault(0xbA1333333333a1BA1108E8412f11850A5C319bA9); + + /** + * @notice Data for the swap hook. + * @param pool Address of the liquidity pool + * @param tokenIn Token to be swapped from + * @param tokenOut Token to be swapped to + * @param amountGiven Amount given based on kind of the swap + * @param transferType Type of transfer to be used for the token in + * @param receiver Address to receive the output token + */ + struct SwapHookParams { + address pool; + IERC20 tokenIn; + IERC20 tokenOut; + uint256 amountGiven; + TransferType transferType; + address receiver; + } + + constructor(address _permit2) RestrictTransferFrom(_permit2) {} + + modifier onlyVault() { + if (msg.sender != address(VAULT)) { + revert BalancerV3Executor__SenderIsNotVault(msg.sender); + } + _; + } + + // slither-disable-next-line locked-ether + function swap(uint256 givenAmount, bytes calldata data) + external + payable + returns (uint256 calculatedAmount) + { + ( + IERC20 tokenIn, + IERC20 tokenOut, + address poolId, + TransferType transferType, + address receiver + ) = _decodeData(data); + + calculatedAmount = abi.decode( + VAULT.unlock( + abi.encodeCall( + BalancerV3Executor.swapHook, + SwapHookParams({ + pool: poolId, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountGiven: givenAmount, + transferType: transferType, + receiver: receiver + }) + ) + ), + (uint256) + ); + } + + /** + * @notice Hook to be called by the Balancer Vault. + * @param params Parameters for the swap hook. + * @return amountCalculated The amount calculated after the swap. + */ + function swapHook(SwapHookParams calldata params) + external + onlyVault + returns (uint256 amountCalculated) + { + uint256 amountIn; + uint256 amountOut; + (amountCalculated, amountIn, amountOut) = VAULT.swap( + VaultSwapParams({ + kind: SwapKind.EXACT_IN, + pool: params.pool, + tokenIn: params.tokenIn, + tokenOut: params.tokenOut, + amountGivenRaw: params.amountGiven, + limitRaw: 0, + userData: "" + }) + ); + + _transfer( + address(VAULT), + params.transferType, + address(params.tokenIn), + amountIn + ); + // slither-disable-next-line unused-return + VAULT.settle(params.tokenIn, amountIn); + VAULT.sendTo(params.tokenOut, params.receiver, amountOut); + + return amountCalculated; + } + + function _decodeData(bytes calldata data) + internal + pure + returns ( + IERC20 tokenIn, + IERC20 tokenOut, + address poolId, + TransferType transferType, + address receiver + ) + { + if (data.length != 81) { + revert BalancerV3Executor__InvalidDataLength(); + } + + tokenIn = IERC20(address(bytes20(data[0:20]))); + tokenOut = IERC20(address(bytes20(data[20:40]))); + poolId = address(bytes20(data[40:60])); + transferType = TransferType(uint8(data[60])); + receiver = address(bytes20(data[61:81])); + } +} diff --git a/foundry/test/executors/BalancerV2Executor.t.sol b/foundry/test/executors/BalancerV2Executor.t.sol index 6b099f3..6c832d6 100644 --- a/foundry/test/executors/BalancerV2Executor.t.sol +++ b/foundry/test/executors/BalancerV2Executor.t.sol @@ -84,7 +84,7 @@ contract BalancerV2ExecutorTest is Constants, TestUtils { WETH_BAL_POOL_ID, BOB, true, - RestrictTransferFrom.TransferType.Transfer + RestrictTransferFrom.TransferType.None ); deal(WETH_ADDR, address(balancerV2Exposed), amountIn); diff --git a/foundry/test/executors/BalancerV3Executor.t.sol b/foundry/test/executors/BalancerV3Executor.t.sol new file mode 100644 index 0000000..0114863 --- /dev/null +++ b/foundry/test/executors/BalancerV3Executor.t.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "../../src/executors/BalancerV3Executor.sol"; +import "../TestUtils.sol"; +import "@src/executors/BalancerV3Executor.sol"; +import {Constants} from "../Constants.sol"; + +contract BalancerV3ExecutorExposed is BalancerV3Executor { + constructor(address _permit2) BalancerV3Executor(_permit2) {} + + function decodeParams(bytes calldata data) + external + pure + returns ( + IERC20 tokenIn, + IERC20 tokenOut, + address poolId, + TransferType transferType, + address receiver + ) + { + return _decodeData(data); + } +} + +contract BalancerV3ExecutorTest is Constants, TestUtils { + using SafeERC20 for IERC20; + + BalancerV3ExecutorExposed balancerV3Exposed; + address WETH_osETH_pool = + address(0x57c23c58B1D8C3292c15BEcF07c62C5c52457A42); + address osETH_ADDR = address(0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38); + address waEthWETH_ADDR = address(0x0bfc9d54Fc184518A81162F8fB99c2eACa081202); + + function setUp() public { + uint256 forkBlock = 22625131; + vm.createSelectFork(vm.rpcUrl("mainnet"), forkBlock); + balancerV3Exposed = new BalancerV3ExecutorExposed(PERMIT2_ADDRESS); + } + + function testDecodeParams() public view { + bytes memory params = abi.encodePacked( + osETH_ADDR, + waEthWETH_ADDR, + WETH_osETH_pool, + RestrictTransferFrom.TransferType.None, + BOB + ); + + ( + IERC20 tokenIn, + IERC20 tokenOut, + address poolId, + RestrictTransferFrom.TransferType transferType, + address receiver + ) = balancerV3Exposed.decodeParams(params); + + assertEq(address(tokenIn), osETH_ADDR); + assertEq(address(tokenOut), waEthWETH_ADDR); + assertEq(poolId, WETH_osETH_pool); + assertEq( + uint8(transferType), uint8(RestrictTransferFrom.TransferType.None) + ); + assertEq(receiver, BOB); + } + + function testDecodeParamsInvalidDataLength() public { + bytes memory invalidParams = abi.encodePacked( + osETH_ADDR, + waEthWETH_ADDR, + WETH_osETH_pool, + RestrictTransferFrom.TransferType.None + ); + + vm.expectRevert(BalancerV3Executor__InvalidDataLength.selector); + balancerV3Exposed.decodeParams(invalidParams); + } + + function testSwap() public { + uint256 amountIn = 10 ** 18; + bytes memory protocolData = abi.encodePacked( + osETH_ADDR, + waEthWETH_ADDR, + WETH_osETH_pool, + RestrictTransferFrom.TransferType.Transfer, + BOB + ); + + deal(osETH_ADDR, address(balancerV3Exposed), amountIn); + + uint256 balanceBefore = IERC20(waEthWETH_ADDR).balanceOf(BOB); + + uint256 amountOut = balancerV3Exposed.swap(amountIn, protocolData); + + uint256 balanceAfter = IERC20(waEthWETH_ADDR).balanceOf(BOB); + assertGt(balanceAfter, balanceBefore); + assertEq(balanceAfter - balanceBefore, amountOut); + } +}