Skip to content

Commit 5b17b0f

Browse files
authored
refactor: Send funds directly to recipient for ERC20 intents (#6750)
1 parent 9fef23d commit 5b17b0f

File tree

6 files changed

+459
-249
lines changed

6 files changed

+459
-249
lines changed

.changeset/gold-islands-agree.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperlane-xyz/core": minor
3+
---
4+
5+
Add Everclear bridges for ETH and ERC20 tokens.

solidity/contracts/mock/MockMailbox.sol

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {TestPostDispatchHook} from "../test/TestPostDispatchHook.sol";
1414

1515
contract MockMailbox is Mailbox {
1616
using Message for bytes;
17+
using TypeCasts for address;
1718

1819
uint32 public inboundUnprocessedNonce = 0;
1920
uint32 public inboundProcessedNonce = 0;
@@ -92,4 +93,37 @@ contract MockMailbox is Mailbox {
9293
function addInboundMetadata(uint32 _nonce, bytes memory metadata) public {
9394
inboundMetadata[_nonce] = metadata;
9495
}
96+
97+
function buildMessage(
98+
address sender,
99+
uint32 destinationDomain,
100+
bytes32 recipientAddress,
101+
bytes calldata messageBody
102+
) external view returns (bytes memory) {
103+
return
104+
_buildMessage(
105+
sender,
106+
destinationDomain,
107+
recipientAddress,
108+
messageBody
109+
);
110+
}
111+
112+
function _buildMessage(
113+
address sender,
114+
uint32 destinationDomain,
115+
bytes32 recipientAddress,
116+
bytes calldata messageBody
117+
) internal view returns (bytes memory) {
118+
return
119+
Message.formatMessage(
120+
VERSION,
121+
nonce,
122+
localDomain,
123+
sender.addressToBytes32(),
124+
destinationDomain,
125+
recipientAddress,
126+
messageBody
127+
);
128+
}
95129
}

solidity/contracts/token/bridge/EverclearEthBridge.sol

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ contract EverclearEthBridge is EverclearTokenBridge {
2525

2626
/**
2727
* @notice Constructor to initialize the Everclear ETH bridge
28+
* @param _weth The WETH contract address for wrapping/unwrapping ETH
29+
* @param _scale The scaling factor for token amounts (typically 1 for 18-decimal tokens)
30+
* @param _mailbox The address of the Hyperlane mailbox contract
2831
* @param _everclearAdapter The address of the Everclear adapter contract
2932
*/
3033
constructor(
@@ -41,6 +44,27 @@ contract EverclearEthBridge is EverclearTokenBridge {
4144
)
4245
{}
4346

47+
/**
48+
* @notice Gets the receiver address for an ETH transfer intent
49+
* @dev Overrides parent to use the remote router instead of direct recipient
50+
* @param _destination The destination domain ID
51+
* @return receiver The remote router address that will handle the ETH transfer
52+
*/
53+
function _getReceiver(
54+
uint32 _destination,
55+
bytes32 /* _recipient */
56+
) internal view override returns (bytes32 receiver) {
57+
return _mustHaveRemoteRouter(_destination);
58+
}
59+
60+
/**
61+
* @notice Provides a quote for transferring ETH to a remote chain
62+
* @dev Overrides parent to return a single quote for ETH (including transfer amount, fees, and gas)
63+
* @param _destination The destination domain ID
64+
* @param _recipient The recipient address on the destination chain
65+
* @param _amount The amount of ETH to transfer
66+
* @return quotes Array containing a single quote with total ETH amount needed
67+
*/
4468
function quoteTransferRemote(
4569
uint32 _destination,
4670
bytes32 _recipient,
@@ -57,6 +81,8 @@ contract EverclearEthBridge is EverclearTokenBridge {
5781

5882
/**
5983
* @notice Transfers ETH from sender, wrapping to WETH
84+
* @dev Requires msg.value to be at least the specified amount, then wraps ETH to WETH
85+
* @param _amount The amount of ETH to wrap to WETH (includes transfer amount and fees)
6086
*/
6187
function _transferFromSender(uint256 _amount) internal override {
6288
// The `_amount` here will be amount + fee where amount is what the user wants to send,
@@ -66,6 +92,12 @@ contract EverclearEthBridge is EverclearTokenBridge {
6692
IWETH(address(wrappedToken)).deposit{value: _amount}();
6793
}
6894

95+
/**
96+
* @notice Transfers ETH to a recipient by unwrapping WETH and sending native ETH
97+
* @dev Unwraps WETH to ETH and uses Address.sendValue for safe ETH transfer
98+
* @param _recipient The address to receive the ETH
99+
* @param _amount The amount of ETH to transfer
100+
*/
69101
function _transferTo(
70102
address _recipient,
71103
uint256 _amount
@@ -77,6 +109,14 @@ contract EverclearEthBridge is EverclearTokenBridge {
77109
payable(_recipient).sendValue(_amount);
78110
}
79111

112+
/**
113+
* @notice Charges the sender for ETH transfer including all fees
114+
* @dev Overrides parent to handle ETH-specific charging logic with fee calculation and distribution
115+
* @param _destination The destination domain ID
116+
* @param _recipient The recipient address on the destination chain
117+
* @param _amount The amount of ETH to transfer (excluding fees)
118+
* @return dispatchValue The remaining ETH value to include with the Hyperlane message dispatch
119+
*/
80120
function _chargeSender(
81121
uint32 _destination,
82122
bytes32 _recipient,
@@ -92,4 +132,15 @@ contract EverclearEthBridge is EverclearTokenBridge {
92132
}
93133
return dispatchValue;
94134
}
135+
136+
/**
137+
* @notice Allows the contract to receive ETH
138+
* @dev Required for WETH unwrapping functionality
139+
*/
140+
receive() external payable {
141+
require(
142+
msg.sender == address(wrappedToken),
143+
"EEB: Only WETH can send ETH"
144+
);
145+
}
95146
}

solidity/contracts/token/bridge/EverclearTokenBridge.sol

Lines changed: 84 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {PackageVersioned} from "../../PackageVersioned.sol";
1111
import {IWETH} from "../interfaces/IWETH.sol";
1212
import {TokenMessage} from "../libs/TokenMessage.sol";
1313
import {TypeCasts} from "../../libs/TypeCasts.sol";
14+
import {FungibleTokenRouter} from "../libs/FungibleTokenRouter.sol";
1415

1516
/**
1617
* @notice Information about an output asset for a destination domain
@@ -68,6 +69,9 @@ contract EverclearTokenBridge is HypERC20Collateral {
6869

6970
/**
7071
* @notice Constructor to initialize the Everclear token bridge
72+
* @param _erc20 The address of the ERC20 token to be bridged
73+
* @param _scale The scaling factor for token amounts (typically 1 for 18-decimal tokens)
74+
* @param _mailbox The address of the Hyperlane mailbox contract
7175
* @param _everclearAdapter The address of the Everclear adapter contract
7276
*/
7377
constructor(
@@ -81,8 +85,10 @@ contract EverclearTokenBridge is HypERC20Collateral {
8185
}
8286

8387
/**
84-
* @notice Initializes the proxy contract.
85-
* @dev Approves the Everclear adapter to spend tokens
88+
* @notice Initializes the proxy contract
89+
* @dev Approves the Everclear adapter to spend tokens and calls parent initialization
90+
* @param _hook The address of the post-dispatch hook (can be zero address)
91+
* @param _owner The address that will own this contract
8692
*/
8793
function initialize(address _hook, address _owner) public initializer {
8894
_HypERC20_initialize(_hook, address(0), _owner);
@@ -109,6 +115,11 @@ contract EverclearTokenBridge is HypERC20Collateral {
109115
emit FeeParamsUpdated(_fee, _deadline);
110116
}
111117

118+
/**
119+
* @notice Internal function to set the output asset for a destination domain
120+
* @dev Emits OutputAssetSet event when successful
121+
* @param _outputAssetInfo The output asset information containing destination and asset address
122+
*/
112123
function _setOutputAsset(
113124
OutputAssetInfo calldata _outputAssetInfo
114125
) internal {
@@ -172,27 +183,13 @@ contract EverclearTokenBridge is HypERC20Collateral {
172183
});
173184
}
174185

175-
/// @dev We can't use _feeAmount here because Everclear wants to pull tokens from this contract
176-
/// and the amount from _feeAmount is sent to the fee recipient.
177-
function _chargeSender(
178-
uint32 _destination,
179-
bytes32 _recipient,
180-
uint256 _amount
181-
) internal virtual override returns (uint256 dispatchValue) {
182-
return
183-
super._chargeSender(
184-
_destination,
185-
_recipient,
186-
_amount + feeParams.fee
187-
);
188-
}
189-
190186
/**
191187
* @notice Creates an Everclear intent for cross-chain token transfer
192188
* @dev Internal function to handle intent creation with Everclear adapter
193189
* @param _destination The destination domain ID
194190
* @param _recipient The recipient address on the destination chain
195191
* @param _amount The amount of tokens to transfer
192+
* @return The created Everclear intent struct containing all transfer details
196193
*/
197194
function _createIntent(
198195
uint32 _destination,
@@ -209,40 +206,72 @@ contract EverclearTokenBridge is HypERC20Collateral {
209206
destinations[0] = _destination;
210207

211208
// Create intent
212-
// We always send the funds to the remote router, which will then send them to the recipient in _handle
213209
(, IEverclear.Intent memory intent) = everclearAdapter.newIntent({
214210
_destinations: destinations,
215-
_receiver: _mustHaveRemoteRouter(_destination),
211+
_receiver: _getReceiver(_destination, _recipient),
216212
_inputAsset: address(wrappedToken),
217213
_outputAsset: outputAssets[_destination], // We load this from storage again to avoid stack too deep
218214
_amount: _amount,
219215
_maxFee: 0,
220216
_ttl: 0,
221-
_data: _getIntentCalldata(_recipient, _amount),
217+
_data: "",
222218
_feeParams: feeParams
223219
});
224220

225221
return intent;
226222
}
227223

228224
/**
229-
* @notice Gets the calldata for the intent that will unwrap WETH to ETH on destination
230-
* @dev Overrides parent to return calldata for unwrapping WETH to ETH
231-
* @return The encoded calldata for the unwrap and send operation
225+
* @notice Gets the receiver address for an intent
226+
* @dev Virtual function that can be overridden by derived contracts
227+
* @param _destination The destination domain ID
228+
* @param _recipient The intended recipient address
229+
* @return receiver The receiver address to use in the intent (typically the recipient for token bridge)
232230
*/
233-
function _getIntentCalldata(
231+
function _getReceiver(
232+
uint32 _destination,
233+
bytes32 _recipient
234+
) internal view virtual returns (bytes32) {
235+
return _recipient;
236+
}
237+
238+
/**
239+
* @notice Charges the sender for the transfer including Everclear fees
240+
* @dev We can't use _feeAmount here because Everclear wants to pull tokens from this contract
241+
* and the amount from _feeAmount is sent to the fee recipient.
242+
* @param _destination The destination domain ID
243+
* @param _recipient The recipient address on the destination chain
244+
* @param _amount The amount of tokens to transfer (excluding fees)
245+
* @return dispatchValue The ETH value to include with the Hyperlane message dispatch
246+
*/
247+
function _chargeSender(
248+
uint32 _destination,
234249
bytes32 _recipient,
235250
uint256 _amount
236-
) internal view returns (bytes memory) {
237-
return abi.encode(_recipient, _amount);
251+
) internal virtual override returns (uint256 dispatchValue) {
252+
return
253+
super._chargeSender(
254+
_destination,
255+
_recipient,
256+
_amount + feeParams.fee
257+
);
238258
}
239259

260+
/**
261+
* @notice Handles pre-dispatch logic including charging sender and creating Everclear intent
262+
* @dev Overrides parent function to integrate with Everclear's intent system
263+
* @param _destination The destination domain ID
264+
* @param _recipient The recipient address on the destination chain
265+
* @param _amount The amount of tokens to transfer
266+
* @return dispatchValue The ETH value to include with the message dispatch
267+
* @return message The encoded message containing transfer details and intent
268+
*/
240269
function _beforeDispatch(
241270
uint32 _destination,
242271
bytes32 _recipient,
243272
uint256 _amount
244273
) internal virtual override returns (uint256, bytes memory) {
245-
(uint256 _dispatchValue, bytes memory _msg) = super._beforeDispatch(
274+
uint256 dispatchValue = _chargeSender(
246275
_destination,
247276
_recipient,
248277
_amount
@@ -254,45 +283,51 @@ contract EverclearTokenBridge is HypERC20Collateral {
254283
_amount
255284
);
256285

257-
// Add the intent to the `TokenMessage` as metadata
258-
// The original `_msg` is abi.encodePacked(_recipient, _amount)
259-
// We need can't use abi.encodePacked because the intent is a struct
260-
_msg = bytes.concat(_msg, abi.encode(intent));
286+
bytes memory message = TokenMessage.format(
287+
_recipient,
288+
_outboundAmount(_amount),
289+
abi.encode(intent)
290+
);
261291

262-
return (_dispatchValue, _msg);
292+
return (dispatchValue, message);
263293
}
264294

295+
/**
296+
* @dev No-op, the funds are transferred directly to `_recipient` via Everclear
297+
* @param _recipient The address to receive the tokens
298+
* @param _amount The amount of tokens to transfer
299+
*/
300+
function _transferTo(
301+
address _recipient,
302+
uint256 _amount
303+
) internal virtual override {
304+
// No-op, the funds are transferred directly to `_recipient` via Everclear
305+
}
306+
307+
/**
308+
* @notice Handles incoming messages from remote chains
309+
* @dev For the base token bridge, this is a no-op since funds are transferred via Everclear
310+
* @param _origin The origin domain ID where the message was sent from
311+
* @param _message The message payload (unused in base implementation)
312+
*/
265313
function _handle(
266314
uint32 _origin,
267-
bytes32 /* sender */,
315+
bytes32 _sender,
268316
bytes calldata _message
269317
) internal virtual override {
270318
// Get intent from hyperlane message
271319
bytes memory metadata = _message.metadata();
272-
IEverclear.Intent memory intent = abi.decode(
273-
metadata,
274-
(IEverclear.Intent)
275-
);
320+
bytes32 intentId = keccak256(metadata);
276321

277-
/* CHECKS */
278-
// Check that intent is settled
279-
bytes32 intentId = keccak256(abi.encode(intent));
322+
// Check Everclear intent status
280323
require(
281324
everclearSpoke.status(intentId) == IEverclear.IntentStatus.SETTLED,
282325
"ETB: Intent Status != SETTLED"
283326
);
284327
// Check that we have not processed this intent before
285328
require(!intentSettled[intentId], "ETB: Intent already processed");
286-
(bytes32 _recipient, uint256 _amount) = abi.decode(
287-
intent.data,
288-
(bytes32, uint256)
289-
);
290329

291-
/* EFFECTS */
292330
intentSettled[intentId] = true;
293-
emit ReceivedTransferRemote(_origin, _recipient, _amount);
294-
295-
/* INTERACTIONS */
296-
_transferTo(_recipient.bytes32ToAddress(), _amount);
331+
super._handle(_origin, _sender, _message);
297332
}
298333
}

solidity/foundry.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ cache_path = 'forge-cache'
88
allow_paths = ["../node_modules"]
99
solc_version = '0.8.22'
1010
evm_version= 'paris'
11-
optimizer = true
11+
# optimizer = true
1212
optimizer_runs = 999_999
1313
fs_permissions = [
1414
{ access = "read", path = "./script/avs/"},

0 commit comments

Comments
 (0)