Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions script/DeployV2Core.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ contract DeployV2Core is DeployV2Base, ConfigCore {
address circleGatewayAddDelegateHook;
address circleGatewayRemoveDelegateHook;
address swapUniswapV4Hook;
address transferHook;
}

struct HookDeployment {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -307,7 +308,8 @@ contract DeployV2Core is DeployV2Base, ConfigCore {
"CircleGatewayMinterHook",
"CircleGatewayAddDelegateHook",
"CircleGatewayRemoveDelegateHook",
"SwapUniswapV4Hook"
"SwapUniswapV4Hook",
"TransferHook"
];

// Start with all hooks, then decrement for missing configurations
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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! ");

Expand Down
1 change: 1 addition & 0 deletions script/output/local-cosmin/1/Ethereum-latest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions script/output/local-cosmin/10/Optimism-latest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions script/output/local-cosmin/8453/Base-latest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions script/utils/Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
110 changes: 110 additions & 0 deletions src/hooks/tokens/TransferHook.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
10 changes: 2 additions & 8 deletions src/hooks/tokens/permit2/BatchTransferFromHook.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}

Expand Down Expand Up @@ -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()
});
}
}
Expand Down
144 changes: 144 additions & 0 deletions test/unit/hooks/tokens/TransferHook.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}