diff --git a/.gitmodules b/.gitmodules index 5d16ef5..61440eb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "foundry/lib/v3-periphery"] path = foundry/lib/v3-periphery url = https://github.com/Uniswap/v3-periphery +[submodule "foundry/lib/balancer-v2-monorepo"] + path = foundry/lib/balancer-v2-monorepo + url = https://github.com/balancer/balancer-v2-monorepo diff --git a/CHANGELOG.md b/CHANGELOG.md index 81cc2d5..4d5bbdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## [0.21.0](https://github.com/propeller-heads/tycho-execution/compare/0.20.0...0.21.0) (2025-01-28) + + +### Features + +* add balancer v2 executor ([a700189](https://github.com/propeller-heads/tycho-execution/commit/a700189aaf8364a55e9625c807191232663eeff8)) +* add node.js workflow ([25756ff](https://github.com/propeller-heads/tycho-execution/commit/25756fffdde57ba49985006702eee219cddeb262)) +* add tests for Balancer V2 executor ([a4e405f](https://github.com/propeller-heads/tycho-execution/commit/a4e405fb7541c96445e820db4bd48110801ad940)) +* approve max ([cb6d165](https://github.com/propeller-heads/tycho-execution/commit/cb6d165e7f901ee16a8848361a22bcb613b83c69)) +* update remappings and remove node modules ([b65b682](https://github.com/propeller-heads/tycho-execution/commit/b65b682e8db4950fc9886dc00f2d76f6239447a8)) + + +### Bug Fixes + +* balancer v2 encoder bug ([a6a624b](https://github.com/propeller-heads/tycho-execution/commit/a6a624b740c8260f63caa46707d3ffb04cc6fca2)) +* build ([5dc5e23](https://github.com/propeller-heads/tycho-execution/commit/5dc5e23239dd01c5cde5740c1d8f7a914103d54b)) +* ci ([b1ca478](https://github.com/propeller-heads/tycho-execution/commit/b1ca4782941699c548c0d6c2b3aa60711598780f)) +* clippy ([877f625](https://github.com/propeller-heads/tycho-execution/commit/877f625efc307eb902f65fcf2e1b9a052204d8f3)) +* exclude node modules from slither ([4b3c5c5](https://github.com/propeller-heads/tycho-execution/commit/4b3c5c5005e52f53b4a21f28c80b46f7c409ee01)) +* filter paths slither ([6c30cf8](https://github.com/propeller-heads/tycho-execution/commit/6c30cf8f66c7e5b95f3d216c2a3408e34886a852)) +* rm exactOut ([44db2e5](https://github.com/propeller-heads/tycho-execution/commit/44db2e52b31bbc208325e99f86b7ebad05be65ce)) +* slither ([b854282](https://github.com/propeller-heads/tycho-execution/commit/b85428212a40b7cb0d31fb57027675f7e6a5cf6f)) +* slither ([7a83edd](https://github.com/propeller-heads/tycho-execution/commit/7a83eddc92333638247a61acd1154eb65a510467)) + ## [0.20.0](https://github.com/propeller-heads/tycho-execution/compare/0.19.0...0.20.0) (2025-01-28) diff --git a/Cargo.lock b/Cargo.lock index 6cf2059..82f4bcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4163,7 +4163,7 @@ dependencies = [ [[package]] name = "tycho-execution" -version = "0.20.0" +version = "0.21.0" dependencies = [ "alloy", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index 36b21a3..a1172fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycho-execution" -version = "0.20.0" +version = "0.21.0" edition = "2021" [dependencies] 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/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 +