diff --git a/.gitmodules b/.gitmodules index d9919b8..025aab1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -22,3 +22,6 @@ [submodule "foundry/lib/solady"] path = foundry/lib/solady url = https://github.com/vectorized/solady +[submodule "foundry/lib/balancer-v3-monorepo"] + path = foundry/lib/balancer-v3-monorepo + url = https://github.com/balancer/balancer-v3-monorepo diff --git a/foundry/lib/balancer-v3-monorepo b/foundry/lib/balancer-v3-monorepo new file mode 160000 index 0000000..84cfb0d --- /dev/null +++ b/foundry/lib/balancer-v3-monorepo @@ -0,0 +1 @@ +Subproject commit 84cfb0d9d0bd024f92c7a95c64ab7ab8b4c12f4b 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..3c09c46 --- /dev/null +++ b/foundry/src/executors/BalancerV3Executor.sol @@ -0,0 +1,108 @@ +// 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"; +import {ICallback} from "../../interfaces/ICallback.sol"; + +error BalancerV3Executor__InvalidDataLength(); +error BalancerV3Executor__SenderIsNotVault(address sender); + +contract BalancerV3Executor is IExecutor, RestrictTransferFrom, ICallback { + using SafeERC20 for IERC20; + + IVault private constant VAULT = + IVault(0xbA1333333333a1BA1108E8412f11850A5C319bA9); + + constructor(address _permit2) RestrictTransferFrom(_permit2) {} + + // slither-disable-next-line locked-ether + function swap(uint256 givenAmount, bytes calldata data) + external + payable + returns (uint256 calculatedAmount) + { + bytes memory result = VAULT.unlock( + abi.encodeCall( + BalancerV3Executor.handleCallback, + abi.encodePacked(givenAmount, data) + ) + ); + calculatedAmount = abi.decode(abi.decode(result, (bytes)), (uint256)); + } + + function verifyCallback(bytes calldata /*data*/ ) public view { + if (msg.sender != address(VAULT)) { + revert BalancerV3Executor__SenderIsNotVault(msg.sender); + } + } + + function handleCallback(bytes calldata data) + external + returns (bytes memory result) + { + verifyCallback(data); + ( + uint256 amountGiven, + IERC20 tokenIn, + IERC20 tokenOut, + address poolId, + TransferType transferType, + address receiver + ) = _decodeData(data); + + uint256 amountCalculated; + uint256 amountIn; + uint256 amountOut; + (amountCalculated, amountIn, amountOut) = VAULT.swap( + VaultSwapParams({ + kind: SwapKind.EXACT_IN, + pool: poolId, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountGivenRaw: amountGiven, + limitRaw: 0, + userData: "" + }) + ); + + _transfer(address(VAULT), transferType, address(tokenIn), amountIn); + // slither-disable-next-line unused-return + VAULT.settle(tokenIn, amountIn); + VAULT.sendTo(tokenOut, receiver, amountOut); + return abi.encode(amountCalculated); + } + + function _decodeData(bytes calldata data) + internal + pure + returns ( + uint256 amountGiven, + IERC20 tokenIn, + IERC20 tokenOut, + address poolId, + TransferType transferType, + address receiver + ) + { + if (data.length != 113) { + revert BalancerV3Executor__InvalidDataLength(); + } + + amountGiven = uint256(bytes32(data[0:32])); + tokenIn = IERC20(address(bytes20(data[32:52]))); + tokenOut = IERC20(address(bytes20(data[52:72]))); + poolId = address(bytes20(data[72:92])); + transferType = TransferType(uint8(data[92])); + receiver = address(bytes20(data[93:113])); + } +} 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..51b2e74 --- /dev/null +++ b/foundry/test/executors/BalancerV3Executor.t.sol @@ -0,0 +1,104 @@ +// 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 ( + uint256 amountGiven, + 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( + uint256(1 ether), + osETH_ADDR, + waEthWETH_ADDR, + WETH_osETH_pool, + RestrictTransferFrom.TransferType.None, + BOB + ); + + ( + uint256 amountGiven, + IERC20 tokenIn, + IERC20 tokenOut, + address poolId, + RestrictTransferFrom.TransferType transferType, + address receiver + ) = balancerV3Exposed.decodeParams(params); + + assertEq(amountGiven, 1 ether); + 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); + } +}