diff --git a/script/DeployV2Core.s.sol b/script/DeployV2Core.s.sol index 13dbda11c..e6560998b 100644 --- a/script/DeployV2Core.s.sol +++ b/script/DeployV2Core.s.sol @@ -66,6 +66,7 @@ contract DeployV2Core is DeployV2Base, ConfigCore { address circleGatewayAddDelegateHook; address circleGatewayRemoveDelegateHook; address swapUniswapV4Hook; + address transferHook; } struct HookDeployment { @@ -271,7 +272,7 @@ contract DeployV2Core is DeployV2Base, ConfigCore { availability.expectedAdapters = expectedAdapters; // Hook contracts - all 36 hooks from regenerate_bytecode.sh - string[36] memory baseHooks = [ + string[37] memory baseHooks = [ "ApproveERC20Hook", "TransferERC20Hook", "BatchTransferHook", @@ -307,7 +308,8 @@ contract DeployV2Core is DeployV2Base, ConfigCore { "CircleGatewayMinterHook", "CircleGatewayAddDelegateHook", "CircleGatewayRemoveDelegateHook", - "SwapUniswapV4Hook" + "SwapUniswapV4Hook", + "TransferHook" ]; // Start with all hooks, then decrement for missing configurations @@ -969,6 +971,11 @@ contract DeployV2Core is DeployV2Base, ConfigCore { } else { console2.log("SKIPPED SwapUniswapV4Hook: Uniswap V4 PoolManager not configured for chain", chainId); } + + // TransferHook + __checkContract( + TRANSFER_HOOK_KEY, __getSalt(TRANSFER_HOOK_KEY), abi.encode(configuration.nativeTokens[chainId]), env + ); } /// @notice Check oracle contracts @@ -1677,7 +1684,7 @@ contract DeployV2Core is DeployV2Base, ConfigCore { // Get contract availability for this chain ContractAvailability memory availability = _getContractAvailability(chainId, env); - uint256 len = 36; + uint256 len = 37; HookDeployment[] memory hooks = new HookDeployment[](len); address[] memory addresses = new address[](len); @@ -1867,6 +1874,11 @@ contract DeployV2Core is DeployV2Base, ConfigCore { hooks[35] = HookDeployment("", "", ""); // Empty deployment } + // TransferHook + hooks[36] = _createSafeHookDeploymentWithArgs( + TRANSFER_HOOK_KEY, "TransferHook", env, abi.encode(configuration.nativeTokens[chainId]) + ); + // ===== DEPLOY ALL HOOKS WITH VALIDATION ===== console2.log("Deploying hooks with parameter validation..."); for (uint256 i = 0; i < len; ++i) { @@ -1970,6 +1982,9 @@ contract DeployV2Core is DeployV2Base, ConfigCore { Strings.equal(hooks[34].name, CIRCLE_GATEWAY_REMOVE_DELEGATE_HOOK_KEY) ? addresses[34] : address(0); hookAddresses.swapUniswapV4Hook = Strings.equal(hooks[35].name, SWAP_UNISWAPV4_HOOK_KEY) ? addresses[34] : address(0); + hookAddresses.transferHook = + Strings.equal(hooks[36].name, TRANSFER_HOOK_KEY) ? addresses[36] : address(0); + // ===== FINAL VALIDATION OF ALL CRITICAL HOOKS ===== require(hookAddresses.approveErc20Hook != address(0), "APPROVE_ERC20_HOOK_NOT_ASSIGNED"); @@ -2046,6 +2061,7 @@ contract DeployV2Core is DeployV2Base, ConfigCore { hookAddresses.circleGatewayRemoveDelegateHook != address(0), "CIRCLE_GATEWAY_REMOVE_DELEGATE_HOOK_NOT_ASSIGNED" ); + require(hookAddresses.transferHook != address(0), "TRANSFER_HOOK_NOT_ASSIGNED"); console2.log(" All hooks deployed and validated successfully with comprehensive dependency checking! "); diff --git a/script/output/local-cosmin/1/Ethereum-latest.json b/script/output/local-cosmin/1/Ethereum-latest.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/script/output/local-cosmin/1/Ethereum-latest.json @@ -0,0 +1 @@ +{} diff --git a/script/output/local-cosmin/10/Optimism-latest.json b/script/output/local-cosmin/10/Optimism-latest.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/script/output/local-cosmin/10/Optimism-latest.json @@ -0,0 +1 @@ +{} diff --git a/script/output/local-cosmin/8453/Base-latest.json b/script/output/local-cosmin/8453/Base-latest.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/script/output/local-cosmin/8453/Base-latest.json @@ -0,0 +1 @@ +{} diff --git a/script/utils/Constants.sol b/script/utils/Constants.sol index a2d1f1ae2..8bd3dd322 100644 --- a/script/utils/Constants.sol +++ b/script/utils/Constants.sol @@ -120,6 +120,7 @@ abstract contract Constants { string internal constant YEARN_CLAIM_ONE_REWARD_HOOK_KEY = "YearnClaimOneRewardHook"; string internal constant APPROVE_ERC20_HOOK_KEY = "ApproveERC20Hook"; string internal constant TRANSFER_ERC20_HOOK_KEY = "TransferERC20Hook"; + string internal constant TRANSFER_HOOK_KEY = "TransferHook"; string internal constant BATCH_TRANSFER_HOOK_KEY = "BatchTransferHook"; string internal constant BATCH_TRANSFER_FROM_HOOK_KEY = "BatchTransferFromHook"; string internal constant OFFRAMP_TOKENS_HOOK_KEY = "OfframpTokensHook"; diff --git a/src/hooks/tokens/TransferHook.sol b/src/hooks/tokens/TransferHook.sol new file mode 100644 index 000000000..8341446e7 --- /dev/null +++ b/src/hooks/tokens/TransferHook.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.30; + +// external +import { BytesLib } from "../../vendor/BytesLib.sol"; +import { Execution } from "modulekit/accounts/erc7579/lib/ExecutionLib.sol"; +import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +// Superform +import { BaseHook } from "../BaseHook.sol"; +import { HookSubTypes } from "../../libraries/HookSubTypes.sol"; +import { ISuperHookResult, ISuperHookContextAware, ISuperHookInspector } from "../../interfaces/ISuperHook.sol"; + +/// @title TransferHook +/// @author Superform Labs +/// @dev data has the following structure +/// @notice address token = BytesLib.toAddress(data, 0); +/// @notice address to = BytesLib.toAddress(data, 20); +/// @notice uint256 amount = BytesLib.toUint256(data, 40); +/// @notice bool usePrevHookAmount = _decodeBool(data, 72); +contract TransferHook is BaseHook, ISuperHookContextAware { + uint256 private constant USE_PREV_HOOK_AMOUNT_POSITION = 72; + + /// @dev This is not a constant because some chains have different representations for the native token + /// https://github.com/d-xo/weird-erc20?tab=readme-ov-file#erc-20-representation-of-native-currency + address public immutable NATIVE_TOKEN; + + constructor(address _nativeToken) BaseHook(HookType.NONACCOUNTING, HookSubTypes.TOKEN) { + NATIVE_TOKEN = _nativeToken; + } + + /*////////////////////////////////////////////////////////////// + VIEW METHODS + //////////////////////////////////////////////////////////////*/ + /// @inheritdoc BaseHook + function _buildHookExecutions( + address prevHook, + address account, + bytes calldata data + ) + internal + view + override + returns (Execution[] memory executions) + { + address token = BytesLib.toAddress(data, 0); + address to = BytesLib.toAddress(data, 20); + uint256 amount = BytesLib.toUint256(data, 40); + bool usePrevHookAmount = _decodeBool(data, USE_PREV_HOOK_AMOUNT_POSITION); + + if (usePrevHookAmount) { + amount = ISuperHookResult(prevHook).getOutAmount(account); + } + + if (amount == 0) revert AMOUNT_NOT_VALID(); + if (token == address(0)) revert ADDRESS_NOT_VALID(); + + // @dev no-revert-on-failure tokens are not supported + executions = new Execution[](1); + if (token == NATIVE_TOKEN) { + // For native token, send ETH directly to the recipient + executions[0] = Execution({ target: to, value: amount, callData: "" }); + } else { + // For ERC20 tokens, use the standard transfer + executions[0] = + Execution({ target: token, value: 0, callData: abi.encodeCall(IERC20.transfer, (to, amount)) }); + } + } + + /*////////////////////////////////////////////////////////////// + EXTERNAL METHODS + //////////////////////////////////////////////////////////////*/ + + /// @inheritdoc ISuperHookContextAware + function decodeUsePrevHookAmount(bytes memory data) external pure returns (bool) { + return _decodeBool(data, USE_PREV_HOOK_AMOUNT_POSITION); + } + + /// @inheritdoc ISuperHookInspector + function inspect(bytes calldata data) external pure override returns (bytes memory) { + return abi.encodePacked( + BytesLib.toAddress(data, 0), //token + BytesLib.toAddress(data, 20) //to + ); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL METHODS + //////////////////////////////////////////////////////////////*/ + function _preExecute(address, address account, bytes calldata data) internal override { + _setOutAmount(_getBalance(data), account); + } + + function _postExecute(address, address account, bytes calldata data) internal override { + _setOutAmount(_getBalance(data) - getOutAmount(account), account); + } + + /*////////////////////////////////////////////////////////////// + PRIVATE METHODS + //////////////////////////////////////////////////////////////*/ + function _getBalance(bytes memory data) private view returns (uint256) { + address token = BytesLib.toAddress(data, 0); + address to = BytesLib.toAddress(data, 20); + if (token == NATIVE_TOKEN) { + return to.balance; + } else { + return IERC20(token).balanceOf(to); + } + } +} diff --git a/src/hooks/tokens/permit2/BatchTransferFromHook.sol b/src/hooks/tokens/permit2/BatchTransferFromHook.sol index 1537cea9d..fe92b4cb3 100644 --- a/src/hooks/tokens/permit2/BatchTransferFromHook.sol +++ b/src/hooks/tokens/permit2/BatchTransferFromHook.sol @@ -114,10 +114,7 @@ contract BatchTransferFromHook is BaseHook { if (amount == 0) revert AMOUNT_NOT_VALID(); vars.details[i] = IAllowanceTransfer.PermitDetails({ - token: token, - amount: amount.toUint160(), - expiration: vars.sigDeadline.toUint48(), - nonce: nonce + token: token, amount: amount.toUint160(), expiration: vars.sigDeadline.toUint48(), nonce: nonce }); } @@ -174,10 +171,7 @@ contract BatchTransferFromHook is BaseHook { uint256 amount = BytesLib.toUint256(amountsData, i * 32); details[i] = IAllowanceTransfer.AllowanceTransferDetails({ - from: from, - to: account, - token: token, - amount: amount.toUint160() + from: from, to: account, token: token, amount: amount.toUint160() }); } } diff --git a/test/unit/hooks/tokens/TransferHook.t.sol b/test/unit/hooks/tokens/TransferHook.t.sol new file mode 100644 index 000000000..567c7f131 --- /dev/null +++ b/test/unit/hooks/tokens/TransferHook.t.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import { Execution } from "modulekit/accounts/erc7579/lib/ExecutionLib.sol"; +import { TransferHook } from "../../../../src/hooks/tokens/TransferHook.sol"; +import { ISuperHook } from "../../../../src/interfaces/ISuperHook.sol"; +import { MockERC20 } from "../../../mocks/MockERC20.sol"; +import { MockHook } from "../../../mocks/MockHook.sol"; +import { BaseHook } from "../../../../src/hooks/BaseHook.sol"; +import { Helpers } from "../../../utils/Helpers.sol"; +import { BytesLib } from "../../../../src/vendor/BytesLib.sol"; + +contract TransferHookTest is Helpers { + using BytesLib for bytes; + + TransferHook public hook; + address public NATIVE_TOKEN = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); + + address token; + address to; + uint256 amount; + + function setUp() public { + MockERC20 _mockToken = new MockERC20("Mock Token", "MTK", 18); + token = address(_mockToken); + + to = address(this); + amount = 1000; + + hook = new TransferHook(NATIVE_TOKEN); + } + + function test_Constructor() public view { + assertEq(uint256(hook.hookType()), uint256(ISuperHook.HookType.NONACCOUNTING)); + assertEq(hook.NATIVE_TOKEN(), NATIVE_TOKEN); + } + + function test_UsePrevHookAmount() public view { + bytes memory data = _encodeData(token, true); + assertTrue(hook.decodeUsePrevHookAmount(data)); + + data = _encodeData(token, false); + assertFalse(hook.decodeUsePrevHookAmount(data)); + } + + function test_Build_ERC20() public view { + bytes memory data = _encodeData(token, false); + Execution[] memory executions = hook.build(address(0), address(0), data); + assertEq(executions.length, 3); + assertEq(executions[1].target, token); + assertEq(executions[1].value, 0); + assertGt(executions[1].callData.length, 0); + } + + function test_Build_NativeToken() public view { + bytes memory data = _encodeData(NATIVE_TOKEN, false); + Execution[] memory executions = hook.build(address(0), address(0), data); + assertEq(executions.length, 3); + assertEq(executions[1].target, to); + assertEq(executions[1].value, amount); + assertEq(executions[1].callData.length, 0); + } + + function test_Build_ERC20_WithPrevHook() public { + uint256 prevHookAmount = 2000; + address mockPrevHook = address(new MockHook(ISuperHook.HookType.INFLOW, token)); + MockHook(mockPrevHook).setOutAmount(prevHookAmount, address(this)); + + bytes memory data = _encodeData(token, true); + Execution[] memory executions = hook.build(mockPrevHook, address(this), data); + assertEq(executions.length, 3); + assertEq(executions[1].target, token); + assertEq(executions[1].value, 0); + assertGt(executions[1].callData.length, 0); + } + + function test_Build_NativeToken_WithPrevHook() public { + uint256 prevHookAmount = 2000; + address mockPrevHook = address(new MockHook(ISuperHook.HookType.INFLOW, NATIVE_TOKEN)); + MockHook(mockPrevHook).setOutAmount(prevHookAmount, address(this)); + + bytes memory data = _encodeData(NATIVE_TOKEN, true); + Execution[] memory executions = hook.build(mockPrevHook, address(this), data); + assertEq(executions.length, 3); + assertEq(executions[1].target, to); + assertEq(executions[1].value, prevHookAmount); + assertEq(executions[1].callData.length, 0); + } + + function test_Build_RevertIf_AddressZero() public { + address zeroToken = address(0); + vm.expectRevert(BaseHook.ADDRESS_NOT_VALID.selector); + hook.build(address(0), address(this), _encodeData(zeroToken, false)); + } + + function test_Build_RevertIf_AmountZero() public { + uint256 zeroAmount = 0; + bytes memory data = abi.encodePacked(token, to, zeroAmount, false); + vm.expectRevert(BaseHook.AMOUNT_NOT_VALID.selector); + hook.build(address(0), address(this), data); + } + + function test_PreAndPostExecute_ERC20() public { + _getTokens(token, address(to), amount); + hook.preExecute(address(0), address(this), _encodeData(token, false)); + assertEq(hook.getOutAmount(address(this)), amount); + + hook.postExecute(address(0), address(this), _encodeData(token, false)); + assertEq(hook.getOutAmount(address(this)), 0); + } + + function test_PreAndPostExecute_NativeToken() public { + // Deal native token to the 'to' address + vm.deal(to, amount); + + hook.preExecute(address(0), address(this), _encodeData(NATIVE_TOKEN, false)); + assertEq(hook.getOutAmount(address(this)), amount); + + hook.postExecute(address(0), address(this), _encodeData(NATIVE_TOKEN, false)); + assertEq(hook.getOutAmount(address(this)), 0); + } + + function test_Inspector_ERC20() public view { + bytes memory data = _encodeData(token, false); + bytes memory argsEncoded = hook.inspect(data); + assertGt(argsEncoded.length, 0); + + assertEq(BytesLib.toAddress(argsEncoded, 0), token); + assertEq(BytesLib.toAddress(argsEncoded, 20), to); + } + + function test_Inspector_NativeToken() public view { + bytes memory data = _encodeData(NATIVE_TOKEN, false); + bytes memory argsEncoded = hook.inspect(data); + assertGt(argsEncoded.length, 0); + + assertEq(BytesLib.toAddress(argsEncoded, 0), NATIVE_TOKEN); + assertEq(BytesLib.toAddress(argsEncoded, 20), to); + } + + function _encodeData(address tokenAddress, bool usePrev) internal view returns (bytes memory) { + return abi.encodePacked(tokenAddress, to, amount, usePrev); + } +}