Skip to content
Draft
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: 5 additions & 0 deletions .changeset/hyp-native-weth-wrapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/core': minor
---

A `HypNativeWethWrapper` adapter and CREATE2-based factory were added, letting WETH holders bridge through an existing `HypNative` route by pulling WETH, unwrapping, and forwarding. `ITokenBridge` was extended with a `token()` accessor (already present on all concrete implementations); `AbstractPredicateWrapper`'s `token` was retyped from `IERC20` to `address` and mocks were updated to expose `token()`.
6 changes: 6 additions & 0 deletions solidity/contracts/interfaces/ITokenBridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ interface ITokenFee {
}

interface ITokenBridge is ITokenFee {
/**
* @notice Returns the ERC20 token managed by this bridge, or address(0) for native.
* @dev Callers use this to know which token to approve for `transferRemote`.
*/
function token() external view returns (address);

/**
* @notice Transfer value to another domain
* @param _destination The destination domain of the message
Expand Down
4 changes: 4 additions & 0 deletions solidity/contracts/mock/MockValueTransferBridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ contract MockValueTransferBridge is Router, ITokenBridge {
_MailboxClient_initialize(_hook, _ism, _owner);
}

function token() external view override returns (address) {
return collateral;
}

function quoteTransferRemote(
uint32 _destinationDomain,
bytes32 _recipient,
Expand Down
71 changes: 53 additions & 18 deletions solidity/contracts/mock/MockWETH.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,75 @@ pragma solidity ^0.8.22;

import {IWETH} from "../token/interfaces/IWETH.sol";

/// @dev Minimal WETH9-compatible mock for tests.
contract MockWETH is IWETH {
function allowance(
address _owner,
address _spender
) external view returns (uint256) {}
string public constant name = "Wrapped Ether";
string public constant symbol = "WETH";
uint8 public constant decimals = 18;

function approve(
address _spender,
uint256 _amount
) external returns (bool) {
return true;
}
mapping(address => uint256) public override balanceOf;
mapping(address => mapping(address => uint256)) public override allowance;

function balanceOf(address _account) external view returns (uint256) {
return 0;
event Deposit(address indexed account, uint256 amount);
event Withdrawal(address indexed account, uint256 amount);

function totalSupply() external view override returns (uint256) {
return address(this).balance;
}

function deposit() external payable {}
function deposit() public payable override {
balanceOf[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}

function totalSupply() external view returns (uint256) {
return address(this).balance;
function withdraw(uint256 amount) external override {
require(balanceOf[msg.sender] >= amount, "WETH: balance");
balanceOf[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "WETH: send failed");
emit Withdrawal(msg.sender, amount);
}

function transfer(address to, uint256 amount) external returns (bool) {
function approve(
address spender,
uint256 amount
) external override returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}

function transfer(
address to,
uint256 amount
) external override returns (bool) {
return _transfer(msg.sender, to, amount);
}

function transferFrom(
address from,
address to,
uint256 amount
) external returns (bool) {
) external override returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
require(allowed >= amount, "WETH: allowance");
allowance[from][msg.sender] = allowed - amount;
}
return _transfer(from, to, amount);
}

function _transfer(
address from,
address to,
uint256 amount
) internal returns (bool) {
require(balanceOf[from] >= amount, "WETH: balance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
return true;
}

function withdraw(uint256 amount) external {}
receive() external payable {
deposit();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ contract TokenBridgeDepositAddress is
_transferOwnership(_owner);
}

function token() public view returns (address) {
function token() public view override returns (address) {
return address(wrappedToken);
}

Expand Down
104 changes: 104 additions & 0 deletions solidity/contracts/token/extensions/HypNativeWethWrapper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;

// ============ Internal Imports ============
import {IWETH} from "../interfaces/IWETH.sol";
import {HypNative} from "../HypNative.sol";
import {ITokenBridge, ITokenFee, Quote} from "../../interfaces/ITokenBridge.sol";
import {Quotes} from "../libs/Quotes.sol";

// ============ External Imports ============
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

/**
* @title HypNativeWethWrapper
* @notice Entry point that pulls WETH from the sender, unwraps to native, and
* forwards the transfer to an existing HypNative router.
* @dev Caller approves WETH for the full amount reported by `quoteTransferRemote`
* (bridged amount + IGP fee + any external fees). Since the wrapper quotes
* and pulls the exact native-equivalent before dispatch, no refund path is
* needed and `msg.value` must be zero.
*/
contract HypNativeWethWrapper is ITokenBridge {
using SafeERC20 for IERC20;
using Quotes for Quote[];

IWETH private immutable weth;
HypNative private immutable hypNative;

constructor(IWETH _weth, HypNative _hypNative) {
require(
_hypNative.token() == address(0),
"Wrapper: HypNative required"
);
weth = _weth;
hypNative = _hypNative;
}

/**
* @notice Returns the ERC20 token callers must approve for `transferRemote`.
* @dev Mirrors `TokenRouter.token()`; always the wrapper's canonical WETH.
*/
function token() external view override returns (address) {
return address(weth);
}

/**
* @inheritdoc ITokenBridge
* @dev Pulls the full native-equivalent (bridged amount + all fees) as WETH,
* unwraps, and forwards to the underlying HypNative.
*/
function transferRemote(
uint32 _destination,
bytes32 _recipient,
uint256 _amount
) external payable override returns (bytes32 messageId) {
require(msg.value == 0, "Wrapper: msg.value must be 0");

uint256 total = hypNative
.quoteTransferRemote(_destination, _recipient, _amount)
.extract(address(0));

IERC20(address(weth)).safeTransferFrom(
msg.sender,
address(this),
total
);
weth.withdraw(total);

messageId = hypNative.transferRemote{value: total}(
_destination,
_recipient,
_amount
);
}

/**
* @inheritdoc ITokenFee
* @dev Mirrors the 3-quote shape of other collateral routers
* (index 0: gas payment, index 1: bridged amount + internal fee,
* index 2: external fee). Each native-denominated entry from the
* underlying HypNative is rewritten to WETH, since the caller pays
* entirely in WETH.
*/
function quoteTransferRemote(
uint32 _destination,
bytes32 _recipient,
uint256 _amount
) external view override returns (Quote[] memory quotes) {
quotes = hypNative.quoteTransferRemote(
_destination,
_recipient,
_amount
);
for (uint256 i = 0; i < quotes.length; i++) {
if (quotes[i].token == address(0)) {
quotes[i].token = address(weth);
}
}
}

// Receive ETH from WETH.withdraw during transferRemote.
receive() external payable {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;

// ============ Internal Imports ============
import {IWETH} from "../interfaces/IWETH.sol";
import {HypNative} from "../HypNative.sol";
import {HypNativeWethWrapper} from "./HypNativeWethWrapper.sol";

// ============ External Imports ============
import {Create2} from "@openzeppelin/contracts/utils/Create2.sol";

/**
* @title HypNativeWethWrapperFactory
* @notice Deploys CREATE2 `HypNativeWethWrapper` instances for a fixed WETH.
* @dev The factory commits to a single WETH address (canonical per chain) and
* produces one deterministic wrapper per `HypNative` router. Wrappers are
* immutable and non-ownable; the factory itself holds no state.
*/
contract HypNativeWethWrapperFactory {
IWETH public immutable weth;

event WrapperDeployed(
HypNative indexed hypNative,
HypNativeWethWrapper wrapper
);

constructor(IWETH _weth) {
weth = _weth;
}

/**
* @notice Deploys a wrapper for `_hypNative` if one does not exist.
* @param _hypNative The HypNative router to wrap.
* @return wrapper The deployed (or pre-existing) wrapper.
*/
function deploy(
HypNative _hypNative
) external returns (HypNativeWethWrapper wrapper) {
wrapper = getAddress(_hypNative);
if (address(wrapper).code.length == 0) {
Create2.deploy(0, bytes32(0), _initCode(_hypNative));
emit WrapperDeployed(_hypNative, wrapper);
}
}

/**
* @notice Returns the deterministic wrapper address for `_hypNative`.
*/
function getAddress(
HypNative _hypNative
) public view returns (HypNativeWethWrapper) {
return
HypNativeWethWrapper(
payable(
Create2.computeAddress(
bytes32(0),
keccak256(_initCode(_hypNative))
)
)
);
}

function _initCode(
HypNative _hypNative
) private view returns (bytes memory) {
return
abi.encodePacked(
type(HypNativeWethWrapper).creationCode,
abi.encode(weth, _hypNative)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ contract PredicateCrossCollateralRouterWrapper is
string memory _policyID
) AbstractPredicateWrapper(_crossCollateralRouter, _registry, _policyID) {
// CrossCollateralRouter always has a non-zero token (native not supported)
if (address(token) == address(0))
if (token == address(0))
revert IPredicateWrapper
.PredicateRouterWrapper__NativeTokenUnsupported();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,8 @@ contract PredicateRouterWrapper is AbstractPredicateWrapper {
// ============ Views ============

function tokenType() public view returns (TokenType) {
address tokenAddress = address(token);
if (tokenAddress == address(0)) return TokenType.Native;
if (tokenAddress == address(warpRoute)) return TokenType.Synthetic;
if (token == address(0)) return TokenType.Native;
if (token == address(warpRoute)) return TokenType.Synthetic;
return TokenType.Collateral;
}

Expand Down
17 changes: 8 additions & 9 deletions solidity/contracts/token/libs/AbstractPredicateWrapper.sol
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ abstract contract AbstractPredicateWrapper is
/// @notice The underlying warpRoute being wrapped
TokenRouter public immutable warpRoute;

/// @notice The ERC20 token managed by the warpRoute
IERC20 public immutable token;
/// @notice The ERC20 token managed by the warpRoute (address(0) for native)
address public immutable token;

/// @notice The local domain ID (cached from warpRoute during construction)
uint32 public immutable localDomain;
Expand Down Expand Up @@ -88,15 +88,14 @@ abstract contract AbstractPredicateWrapper is
revert IPredicateWrapper.PredicateRouterWrapper__InvalidPolicy();

warpRoute = TokenRouter(_warpRoute);
address tokenAddress = warpRoute.token();
token = IERC20(tokenAddress);
token = warpRoute.token();
localDomain = warpRoute.localDomain();

_initPredicateClient(_registry, _policyID);

// Infinite approval to warpRoute for token transfers (skip for native)
if (tokenAddress != address(0)) {
IERC20(tokenAddress).forceApprove(_warpRoute, type(uint256).max);
if (token != address(0)) {
IERC20(token).forceApprove(_warpRoute, type(uint256).max);
}
}

Expand All @@ -108,10 +107,10 @@ abstract contract AbstractPredicateWrapper is
revert IPredicateWrapper
.PredicateRouterWrapper__InsufficientValue();

if (address(token) == address(0)) return totalNativeRequired;
uint256 totalTokenRequired = Quotes.extract(quotes, address(token));
if (token == address(0)) return totalNativeRequired;
uint256 totalTokenRequired = Quotes.extract(quotes, token);
if (totalTokenRequired > 0) {
token.safeTransferFrom(
IERC20(token).safeTransferFrom(
msg.sender,
address(this),
totalTokenRequired
Expand Down
2 changes: 1 addition & 1 deletion solidity/contracts/token/libs/TokenRouter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ abstract contract TokenRouter is GasRouter, ITokenBridge {
* @dev This function must be implemented by derived contracts to specify the token address.
* @return The address of the token contract.
*/
function token() public view virtual returns (address);
function token() public view virtual override returns (address);

/**
* @inheritdoc ITokenFee
Expand Down
Loading
Loading