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
5 changes: 4 additions & 1 deletion contracts/base/Dispatcher.sol
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,10 @@ abstract contract Dispatcher is Payments, V2SwapRouter, V3SwapRouter, V4SwapRout
payer = payerIsUser ? sender : address(this);
recipient = recipient == ActionConstants.MSG_SENDER ? sender : recipient;
}
if (amount == ActionConstants.CONTRACT_BALANCE) amount = ERC20(token).balanceOf(address(this));
if (amount == ActionConstants.CONTRACT_BALANCE) {
amount =
token == Constants.ETH ? address(this).balance : ERC20(token).balanceOf(address(this));
}
bridgeToken({
bridgeType: bridgeType,
sender: msgSender(),
Expand Down
2 changes: 2 additions & 0 deletions contracts/libraries/BridgeTypes.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ library BridgeTypes {
// Bridge type identifiers (1 byte)
uint8 constant HYP_XERC20 = 0x01;
uint8 constant XVELO = 0x02;
/// @dev HYP_ERC20_COLLATERAL handles both HypERC20Collateral and HypNative; the native
/// path is selected when the caller passes `token == address(0)`.
uint8 constant HYP_ERC20_COLLATERAL = 0x03;
// Future bridge types can be added here
}
13 changes: 9 additions & 4 deletions contracts/modules/bridge/BridgeRouter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,15 @@ abstract contract BridgeRouter is Permit2Payments {
uint256 tokenFee = amount - bridgeAmount;
if (tokenFee > maxTokenFee) revert TokenFeeExceedsMax(tokenFee, maxTokenFee);

prepareTokensForBridge({_token: token, _bridge: bridge, _payer: payer, _amount: amount});

executeHypBridge({bridge: bridge, recipient: recipient, amount: bridgeAmount, msgFee: msgFee, domain: domain});
ERC20(token).safeApprove({to: bridge, amount: 0});
// Native (HypNative) path: token == address(0). Forward the full native `amount`
// to transferRemote — it covers bridgeAmount + all fees. No ERC20 pull/approve.
if (token == address(0)) {
executeHypBridge({bridge: bridge, recipient: recipient, amount: bridgeAmount, msgFee: amount, domain: domain});
} else {
prepareTokensForBridge({_token: token, _bridge: bridge, _payer: payer, _amount: amount});
executeHypBridge({bridge: bridge, recipient: recipient, amount: bridgeAmount, msgFee: msgFee, domain: domain});
ERC20(token).safeApprove({to: bridge, amount: 0});
}
} else {
revert InvalidBridgeType({bridgeType: bridgeType});
}
Expand Down
275 changes: 274 additions & 1 deletion test/foundry-tests/bridge/bridgeToken/bridgeToken.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {Commands} from '../../../../contracts/libraries/Commands.sol';
import {Constants} from '../../../../contracts/libraries/Constants.sol';
import {IDomainRegistry} from '../../../../contracts/interfaces/external/IDomainRegistry.sol';
import {TypeCasts} from '@hyperlane/core/contracts/libs/TypeCasts.sol';
import {HypNative} from '@hyperlane/core/contracts/token/HypNative.sol';
import {TestPostDispatchHook} from '@hyperlane/core/contracts/test/TestPostDispatchHook.sol';
import {LinearFee} from '@hyperlane/core/contracts/token/fees/LinearFee.sol';
import './BaseOverrideBridge.sol';

contract BridgeTokenTest is BaseOverrideBridge {
Expand All @@ -30,10 +33,17 @@ contract BridgeTokenTest is BaseOverrideBridge {
uint256 public usdcBridgeAmount = 1000 * USDC_1;
uint256 public usdcInitialBal = usdcBridgeAmount * 2;

// HypNative (routed via HYP_ERC20_COLLATERAL sentinel with token=address(0))
HypNative public hypNative;
HypNative public leafHypNative;
uint256 public nativeBridgeAmount = 0.1 ether;
uint256 public nativeInitialBal = 10 ether;
uint256 public leafHypNativeLiquidity = 10 ether;

function setUp() public override {
super.setUp();

deal(address(users.alice), 1 ether);
deal(address(users.alice), nativeInitialBal);
deal(OPEN_USDT_ADDRESS, users.alice, openUsdtInitialBal);
deal(VELO_ADDRESS, users.alice, xVeloBridgeAmount);

Expand All @@ -51,6 +61,28 @@ contract BridgeTokenTest is BaseOverrideBridge {
deal(BASE_USDC_ADDRESS, USDC_BASE_BRIDGE, usdcInitialBal);
vm.selectFork(rootId);

// Deploy HypNative on both forks — no pre-deployed native warp route exists.
vm.selectFork(leafId);
vm.startPrank(users.owner);
leafHypNative = new HypNative(1, 1, address(leafMailbox));
leafHypNative.initialize(address(0), address(0), users.owner);
vm.stopPrank();
vm.deal(address(leafHypNative), leafHypNativeLiquidity); // fund for outbound payouts

vm.selectFork(rootId);
vm.startPrank(users.owner);
hypNative = new HypNative(1, 1, address(rootMailbox));
hypNative.initialize(address(0), address(0), users.owner);
hypNative.enrollRemoteRouter(leafDomain, TypeCasts.addressToBytes32(address(leafHypNative)));
vm.stopPrank();

// Enroll root as remote on leaf
vm.selectFork(leafId);
vm.prank(users.owner);
leafHypNative.enrollRemoteRouter(rootDomain, TypeCasts.addressToBytes32(address(hypNative)));

vm.selectFork(rootId);

inputs = new bytes[](1);

vm.startPrank({msgSender: users.alice});
Expand Down Expand Up @@ -1471,6 +1503,247 @@ contract BridgeTokenTest is BaseOverrideBridge {
vm.snapshotGasLastCall('BridgeRouter_HypERC20Collateral_DirectApproval');
}

/// HYP_NATIVE TESTS (HypNative routed via HYP_ERC20_COLLATERAL with token=address(0)) ///

/// @dev Encodes a BRIDGE_TOKEN input for HypNative using the sentinel. `amount`
/// is CONTRACT_BALANCE so the client passes identical structure to the ERC20
/// flow — the router resolves the input from `address(this).balance` and
/// computes fees via quoteTransferRemote.
modifier whenBridgeTypeIsHYP_NATIVE() {
commands = abi.encodePacked(bytes1(uint8(Commands.BRIDGE_TOKEN)));
inputs = new bytes[](1);
inputs[0] = abi.encode(
uint8(BridgeTypes.HYP_ERC20_COLLATERAL), // sentinel: HypNative selected by token=address(0)
ActionConstants.MSG_SENDER,
Constants.ETH, // token=address(0) → native path
address(hypNative),
ActionConstants.CONTRACT_BALANCE, // consume router's native balance
0, // msgFee unused on native path; branch forwards `amount` as value
nativeBridgeAmount, // maxTokenFee cap
leafDomain,
false // payerIsUser: ignored on native path (no ERC20 pull)
);
_;
}

function _setHypNativeHookFee(uint256 fee) internal returns (uint256 hookFee) {
TestPostDispatchHook(address(rootMailbox.requiredHook())).setFee(fee);
TestPostDispatchHook(address(rootMailbox.defaultHook())).setFee(fee);
hookFee = 2 * fee;
}

/// @dev Verify the bridged native arrives on the destination by processing the
/// inbound mailbox message and asserting alice's leaf balance delta.
function _assertHypNativeDelivery(uint256 bridgeAmount) private {
vm.selectFork(leafId);
uint256 before_ = users.alice.balance;
leafMailbox.processNextInboundMessage();
assertEq(users.alice.balance - before_, bridgeAmount, 'alice received bridgeAmount on leaf');
}

/// @notice Baseline: alice sends native as msg.value, router consumes full balance
/// via CONTRACT_BALANCE. Recipient (= collateral on origin) credited 1:1.
function test_HypNative_WhenNoFees() external whenBasicValidationsPass whenBridgeTypeIsHYP_NATIVE {
uint256 aliceBefore = users.alice.balance;
uint256 collateralBefore = address(hypNative).balance;

vm.expectEmit(address(router));
emit Dispatcher.UniversalRouterBridge(
users.alice, users.alice, Constants.ETH, nativeBridgeAmount, leafDomain
);
router.execute{value: nativeBridgeAmount}(commands, inputs);

assertEq(aliceBefore - users.alice.balance, nativeBridgeAmount, 'alice paid nativeBridgeAmount');
assertEq(
address(hypNative).balance - collateralBefore,
nativeBridgeAmount,
'collateral credited full bridgeAmount (no fees)'
);
assertEq(address(router).balance, 0, 'router drained');

_assertHypNativeDelivery(nativeBridgeAmount);
}

/// @notice Hook fee: same client logic (CONTRACT_BALANCE + maxTokenFee cap). The
/// router auto-deducts the hook fee from the balance — caller forwards the total
/// as msg.value and the bridge credits `amount - hookFee` to the recipient.
function test_HypNative_WithHookFee() external whenBasicValidationsPass whenBridgeTypeIsHYP_NATIVE {
uint256 hookFee = _setHypNativeHookFee(0.001 ether);
uint256 totalIn = nativeBridgeAmount + hookFee;

uint256 aliceBefore = users.alice.balance;
uint256 collateralBefore = address(hypNative).balance;

router.execute{value: totalIn}(commands, inputs);

assertEq(aliceBefore - users.alice.balance, totalIn, 'alice paid totalIn');
assertEq(
address(hypNative).balance - collateralBefore,
nativeBridgeAmount,
'collateral credited bridgeAmount (hook fee absorbed)'
);
assertEq(address(router).balance, 0, 'router drained');

_assertHypNativeDelivery(nativeBridgeAmount);
}

/// @notice Linear warp fee: same client logic. For rate=1%, totalIn=1.01 ETH
/// bridges exactly 1 ETH (quoteExactInputBridgeAmount resolves to amount²/(amount+f(amount))).
function test_HypNative_WithLinearWarpFee() external whenBasicValidationsPass whenBridgeTypeIsHYP_NATIVE {
uint256 expectedBridge = 1 ether;
uint256 totalIn = 1.01 ether; // rate 1%
// override amount and maxTokenFee for the 1%-rate math
inputs[0] = abi.encode(
uint8(BridgeTypes.HYP_ERC20_COLLATERAL),
ActionConstants.MSG_SENDER,
Constants.ETH,
address(hypNative),
ActionConstants.CONTRACT_BALANCE,
0,
totalIn - expectedBridge,
leafDomain,
false
);

vm.stopPrank();
LinearFee linearFee = new LinearFee(address(0), 0.02 ether, 1 ether, users.owner);
vm.prank(users.owner);
hypNative.setFeeRecipient(address(linearFee));
vm.startPrank(users.alice);

uint256 collateralBefore = address(hypNative).balance;

router.execute{value: totalIn}(commands, inputs);

assertEq(
address(hypNative).balance - collateralBefore,
expectedBridge,
'collateral credited 1 ETH bridgeAmount'
);
assertEq(address(linearFee).balance, totalIn - expectedBridge, 'linear fee recipient got overage');
assertEq(address(router).balance, 0, 'router drained');

_assertHypNativeDelivery(expectedBridge);
}

function test_HypNative_WhenTokenFeeExceedsMax() external whenBasicValidationsPass whenBridgeTypeIsHYP_NATIVE {
uint256 hookFee = _setHypNativeHookFee(0.001 ether);
uint256 totalIn = nativeBridgeAmount + hookFee;

// Tighten maxTokenFee to 1 wei below the actual fee
inputs[0] = abi.encode(
uint8(BridgeTypes.HYP_ERC20_COLLATERAL),
ActionConstants.MSG_SENDER,
Constants.ETH,
address(hypNative),
ActionConstants.CONTRACT_BALANCE,
0,
hookFee - 1,
leafDomain,
false
);

vm.expectRevert(abi.encodeWithSelector(BridgeRouter.TokenFeeExceedsMax.selector, hookFee, hookFee - 1));
router.execute{value: totalIn}(commands, inputs);
}

/// @notice Origin-side ERC20→native→bridge composition with the same client
/// logic as the ERC20 collateral flow: TRANSFER_FROM + BRIDGE_TOKEN(CONTRACT_BALANCE).
/// WETH is unwrapped between the steps so the bridge sees native.
function test_HypNative_WethToNative_ThenBridge() external {
uint256 wethIn = nativeBridgeAmount;
weth.deposit{value: wethIn}();
weth.approve(address(router), type(uint256).max);

commands = abi.encodePacked(
bytes1(uint8(Commands.TRANSFER_FROM)),
bytes1(uint8(Commands.UNWRAP_WETH)),
bytes1(uint8(Commands.BRIDGE_TOKEN))
);
inputs = new bytes[](3);
inputs[0] = abi.encode(address(weth), address(router), wethIn);
inputs[1] = abi.encode(ActionConstants.ADDRESS_THIS, 0);
inputs[2] = abi.encode(
uint8(BridgeTypes.HYP_ERC20_COLLATERAL),
ActionConstants.MSG_SENDER,
Constants.ETH,
address(hypNative),
ActionConstants.CONTRACT_BALANCE,
0,
nativeBridgeAmount,
leafDomain,
false
);

uint256 collateralBefore = address(hypNative).balance;

router.execute(commands, inputs);

assertEq(
address(hypNative).balance - collateralBefore,
wethIn,
'collateral credited full WETH-derived native'
);
assertEq(address(router).balance, 0, 'router drained');

_assertHypNativeDelivery(wethIn);
}

/// @notice Combined native hook + linear warp fees with plain CONTRACT_BALANCE.
/// Router resolves input from balance, quote deducts both fees, client passes
/// no pre-computation. For rate=1% and hookFee, totalIn = 1.01 ETH + hookFee →
/// bridgeAmount = 1 ETH.
function test_HypNative_WithCombinedFees() external whenBasicValidationsPass whenBridgeTypeIsHYP_NATIVE {
uint256 hookFee = _setHypNativeHookFee(0.0005 ether);
uint256 expectedBridge = 1 ether;
uint256 totalIn = 1.01 ether + hookFee; // linear 1% + hook

vm.stopPrank();
LinearFee linearFee = new LinearFee(address(0), 0.02 ether, 1 ether, users.owner);
vm.prank(users.owner);
hypNative.setFeeRecipient(address(linearFee));
vm.startPrank(users.alice);

inputs[0] = abi.encode(
uint8(BridgeTypes.HYP_ERC20_COLLATERAL),
ActionConstants.MSG_SENDER,
Constants.ETH,
address(hypNative),
ActionConstants.CONTRACT_BALANCE,
0,
totalIn - expectedBridge,
leafDomain,
false
);

uint256 collateralBefore = address(hypNative).balance;

router.execute{value: totalIn}(commands, inputs);

assertEq(
address(hypNative).balance - collateralBefore,
expectedBridge,
'collateral credited bridgeAmount (hook + linear absorbed)'
);
assertEq(address(linearFee).balance, totalIn - expectedBridge - hookFee, 'linear fee = totalIn - bridge - hook');
assertEq(address(router).balance, 0, 'router drained');

_assertHypNativeDelivery(expectedBridge);
}

function testGas_HypNativeBridge() public whenBridgeTypeIsHYP_NATIVE {
router.execute{value: nativeBridgeAmount}(commands, inputs);
vm.snapshotGasLastCall('BridgeRouter_HypNative');
}

/// @notice Gas snapshot for the "router already holds native" path (e.g. after a
/// preceding swap+unwrap). Mirrors testGas_HypXERC20BridgeRouterBalance.
function testGas_HypNativeBridgeRouterBalance() public whenBridgeTypeIsHYP_NATIVE {
vm.deal(address(router), nativeBridgeAmount);
router.execute(commands, inputs);
vm.snapshotGasLastCall('BridgeRouter_HypNative_RouterBalance');
}

function _assertXVelo(uint256 _bridgeAmount) private {
if (vm.activeFork() == rootId) {
// Verify token transfer occurred
Expand Down