diff --git a/.gitmodules b/.gitmodules index b573165..e830f1a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "foundry/lib/v2-core"] path = foundry/lib/v2-core url = https://github.com/uniswap/v2-core +[submodule "foundry/lib/balancer-v2-monorepo"] + path = foundry/lib/balancer-v2-monorepo + url = https://github.com/balancer/balancer-v2-monorepo diff --git a/cache/solidity-files-cache.json b/cache/solidity-files-cache.json new file mode 100644 index 0000000..6e6ec61 --- /dev/null +++ b/cache/solidity-files-cache.json @@ -0,0 +1 @@ +{"_format":"","paths":{"artifacts":"out","build_infos":"out/build-info","sources":"src","tests":"test","scripts":"script","libraries":["lib"]},"files":{"test/TychoRouter.t.sol":{"lastModificationDate":1737991455132,"contentHash":"7215ee9c7d9dc229d2921a40e899ec5f","sourceName":"test/TychoRouter.t.sol","imports":[],"versionRequirement":null,"artifacts":{},"seenByCompiler":true}},"builds":[],"profiles":{"default":{"solc":{"optimizer":{"enabled":false,"runs":200},"metadata":{"useLiteralContent":false,"bytecodeHash":"ipfs","appendCBOR":true},"outputSelection":{"*":{"*":["abi","evm.bytecode.object","evm.bytecode.sourceMap","evm.bytecode.linkReferences","evm.deployedBytecode.object","evm.deployedBytecode.sourceMap","evm.deployedBytecode.linkReferences","evm.deployedBytecode.immutableReferences","evm.methodIdentifiers","metadata"]}},"evmVersion":"cancun","viaIR":false,"libraries":{}},"vyper":{"evmVersion":"cancun","outputSelection":{"*":{"*":["abi","evm.bytecode","evm.deployedBytecode"]}}}}}} \ No newline at end of file diff --git a/foundry/lib/balancer-v2-monorepo b/foundry/lib/balancer-v2-monorepo new file mode 160000 index 0000000..36d2823 --- /dev/null +++ b/foundry/lib/balancer-v2-monorepo @@ -0,0 +1 @@ +Subproject commit 36d282374b457dddea828be7884ee0d185db06ba diff --git a/foundry/lib/openzeppelin-contracts b/foundry/lib/openzeppelin-contracts index 840c974..acd4ff7 160000 --- a/foundry/lib/openzeppelin-contracts +++ b/foundry/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit 840c974028316f3c8172c1b8e5ed67ad95e255ca +Subproject commit acd4ff74de833399287ed6b31b4debf6b2b35527 diff --git a/foundry/lib/v2-core b/foundry/lib/v2-core index ee547b1..4dd5906 160000 --- a/foundry/lib/v2-core +++ b/foundry/lib/v2-core @@ -1 +1 @@ -Subproject commit ee547b17853e71ed4e0101ccfd52e70d5acded58 +Subproject commit 4dd59067c76dea4a0e8e4bfdda41877a6b16dedc diff --git a/foundry/remappings.txt b/foundry/remappings.txt index f4b9a08..ce5c70e 100644 --- a/foundry/remappings.txt +++ b/foundry/remappings.txt @@ -2,4 +2,5 @@ @interfaces/=interfaces/ @permit2/=lib/permit2/ @src/=src/ -@uniswap-v2/=lib/v2-core/ \ No newline at end of file +@uniswap-v2/=lib/v2-core/ +@balancer-labs/v2-interfaces=lib/balancer-v2-monorepo/pkg/interfaces \ No newline at end of file diff --git a/foundry/slither.config.json b/foundry/slither.config.json index fd69b55..24abc06 100644 --- a/foundry/slither.config.json +++ b/foundry/slither.config.json @@ -1,3 +1,3 @@ { "filter_paths": "lib" -} \ No newline at end of file +} diff --git a/foundry/src/executors/BalancerV2Executor.sol b/foundry/src/executors/BalancerV2Executor.sol new file mode 100644 index 0000000..79777cb --- /dev/null +++ b/foundry/src/executors/BalancerV2Executor.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "@interfaces/IExecutor.sol"; +import { + IERC20, + SafeERC20 +} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +// slither-disable-next-line solc-version +import {IAsset} from "@balancer-labs/v2-interfaces/contracts/vault/IAsset.sol"; +// slither-disable-next-line solc-version +import {IVault} from "@balancer-labs/v2-interfaces/contracts/vault/IVault.sol"; + +error BalancerV2Executor__InvalidDataLength(); + +contract BalancerV2Executor is IExecutor { + using SafeERC20 for IERC20; + + address private constant VAULT = 0xBA12222222228d8Ba445958a75a0704d566BF2C8; + + // slither-disable-next-line locked-ether + function swap(uint256 givenAmount, bytes calldata data) + external + payable + returns (uint256 calculatedAmount) + { + ( + IERC20 tokenIn, + IERC20 tokenOut, + bytes32 poolId, + address receiver, + bool needsApproval + ) = _decodeData(data); + + if (needsApproval) { + // slither-disable-next-line unused-return + tokenIn.approve(VAULT, type(uint256).max); + } + + IVault.SingleSwap memory singleSwap = IVault.SingleSwap({ + poolId: poolId, + kind: IVault.SwapKind.GIVEN_IN, + assetIn: IAsset(address(tokenIn)), + assetOut: IAsset(address(tokenOut)), + amount: givenAmount, + userData: "" + }); + + IVault.FundManagement memory funds = IVault.FundManagement({ + sender: address(this), + fromInternalBalance: false, + recipient: payable(receiver), + toInternalBalance: false + }); + + uint256 limit = 0; + + calculatedAmount = + IVault(VAULT).swap(singleSwap, funds, limit, block.timestamp); + } + + function _decodeData(bytes calldata data) + internal + pure + returns ( + IERC20 tokenIn, + IERC20 tokenOut, + bytes32 poolId, + address receiver, + bool needsApproval + ) + { + if (data.length != 93) { + revert BalancerV2Executor__InvalidDataLength(); + } + + tokenIn = IERC20(address(bytes20(data[0:20]))); + tokenOut = IERC20(address(bytes20(data[20:40]))); + poolId = bytes32(data[40:72]); + receiver = address(bytes20(data[72:92])); + needsApproval = uint8(data[92]) > 0; + } +} diff --git a/foundry/test/Constants.sol b/foundry/test/Constants.sol index 0feba9d..8776c7d 100644 --- a/foundry/test/Constants.sol +++ b/foundry/test/Constants.sol @@ -22,6 +22,7 @@ contract Constants is Test { // Assets address WETH_ADDR = address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); address DAI_ADDR = address(0x6B175474E89094C44Da98b954EedeAC495271d0F); + address BAL_ADDR = address(0xba100000625a3754423978a60c9317c58a424e3D); address USDC_ADDR = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); address WBTC_ADDR = address(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); diff --git a/foundry/test/executors/BalancerV2Executor.t.sol b/foundry/test/executors/BalancerV2Executor.t.sol new file mode 100644 index 0000000..6d34c9a --- /dev/null +++ b/foundry/test/executors/BalancerV2Executor.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "@src/executors/BalancerV2Executor.sol"; +import {Test} from "../../lib/forge-std/src/Test.sol"; +import {Constants} from "../Constants.sol"; + +contract BalancerV2ExecutorExposed is BalancerV2Executor { + function decodeParams(bytes calldata data) + external + pure + returns ( + IERC20 tokenIn, + IERC20 tokenOut, + bytes32 poolId, + address receiver, + bool needsApproval + ) + { + return _decodeData(data); + } +} + +contract BalancerV2ExecutorTest is + BalancerV2ExecutorExposed, + Test, + Constants +{ + using SafeERC20 for IERC20; + + BalancerV2ExecutorExposed balancerV2Exposed; + IERC20 WETH = IERC20(WETH_ADDR); + IERC20 BAL = IERC20(BAL_ADDR); + bytes32 constant WETH_BAL_POOL_ID = + 0x5c6ee304399dbdb9c8ef030ab642b10820db8f56000200000000000000000014; + + function setUp() public { + uint256 forkBlock = 17323404; + vm.createSelectFork(vm.rpcUrl("mainnet"), forkBlock); + balancerV2Exposed = new BalancerV2ExecutorExposed(); + } + + function testDecodeParams() public view { + bytes memory params = abi.encodePacked( + WETH_ADDR, BAL_ADDR, WETH_BAL_POOL_ID, address(2), true + ); + + ( + IERC20 tokenIn, + IERC20 tokenOut, + bytes32 poolId, + address receiver, + bool needsApproval + ) = balancerV2Exposed.decodeParams(params); + + assertEq(address(tokenIn), WETH_ADDR); + assertEq(address(tokenOut), BAL_ADDR); + assertEq(poolId, WETH_BAL_POOL_ID); + assertEq(receiver, address(2)); + assertEq(needsApproval, true); + } + + function testDecodeParamsInvalidDataLength() public { + bytes memory invalidParams = + abi.encodePacked(WETH_ADDR, BAL_ADDR, WETH_BAL_POOL_ID, address(2)); + + vm.expectRevert(BalancerV2Executor__InvalidDataLength.selector); + balancerV2Exposed.decodeParams(invalidParams); + } + + function testSwap() public { + uint256 amountIn = 10 ** 18; + bytes memory protocolData = + abi.encodePacked(WETH_ADDR, BAL_ADDR, WETH_BAL_POOL_ID, BOB, true); + + deal(WETH_ADDR, address(balancerV2Exposed), amountIn); + uint256 balanceBefore = BAL.balanceOf(BOB); + + uint256 amountOut = balancerV2Exposed.swap(amountIn, protocolData); + + uint256 balanceAfter = BAL.balanceOf(BOB); + assertGt(balanceAfter, balanceBefore); + assertEq(balanceAfter - balanceBefore, amountOut); + } +} diff --git a/src/encoding/evm/swap_encoder/encoders.rs b/src/encoding/evm/swap_encoder/encoders.rs index 51092cb..de13cfc 100644 --- a/src/encoding/evm/swap_encoder/encoders.rs +++ b/src/encoding/evm/swap_encoder/encoders.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use alloy_primitives::Address; +use alloy_primitives::{Address, Bytes as AlloyBytes}; use alloy_sol_types::SolValue; use crate::encoding::{ @@ -150,12 +150,14 @@ impl SwapEncoder for BalancerV2SwapEncoder { Address::from_str(&self.vault_address) .map_err(|_| EncodingError::FatalError("Invalid vault address".to_string()))?, )?; - // should we return gas estimation here too?? if there is an approval needed, gas will be - // higher. + + let component_id = AlloyBytes::from_str(&swap.component.id) + .map_err(|_| EncodingError::FatalError("Invalid component ID".to_string()))?; + let args = ( bytes_to_address(&swap.token_in)?, bytes_to_address(&swap.token_out)?, - swap.component.id, + component_id, bytes_to_address(&encoding_context.receiver)?, encoding_context.exact_out, approval_needed, @@ -266,14 +268,14 @@ mod tests { #[test] fn test_encode_balancer_v2() { let balancer_pool = ProtocolComponent { - id: String::from("0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640"), + id: String::from("0x5c6ee304399dbdb9c8ef030ab642b10820db8f56000200000000000000000014"), protocol_system: String::from("vm:balancer_v2"), ..Default::default() }; let swap = Swap { component: balancer_pool, token_in: Bytes::from("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"), // WETH - token_out: Bytes::from("0x6b175474e89094c44da98b954eedeac495271d0f"), // DAI + token_out: Bytes::from("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"), // BAL split: 0f64, }; let encoding_context = EncodingContext { @@ -293,9 +295,9 @@ mod tests { // token in "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token out - "6b175474e89094c44da98b954eedeac495271d0f", + "2791bca1f2de4661ed88a30c99a7a9449aa84174", // pool id - "307838386536413063326444443236464545623634463033396132633431323936466342336635363430", + "5c6ee304399dbdb9c8ef030ab642b10820db8f56000200000000000000000014", // receiver "0000000000000000000000000000000000000001", // exact out diff --git a/test/TychoRouter.t.sol b/test/TychoRouter.t.sol index 0519ecb..8b13789 100644 --- a/test/TychoRouter.t.sol +++ b/test/TychoRouter.t.sol @@ -1 +1 @@ - \ No newline at end of file +