Skip to content

Commit f7ebf6c

Browse files
nambrot-agentNam's Office Computernambrot
authored
fix(multicollateral): quoteTransferRemoteTo should not require default router mapping (#8303)
Co-authored-by: Nam's Office Computer <namsofficecomputer@Namss-MacBook-Pro.local> Co-authored-by: nambrot <nambrot@googlemail.com>
1 parent 73b2d7c commit f7ebf6c

8 files changed

Lines changed: 539 additions & 49 deletions

File tree

.changeset/mc-destination-gas.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperlane-xyz/multicollateral': minor
3+
'@hyperlane-xyz/sdk': patch
4+
'@hyperlane-xyz/core': patch
5+
---
6+
7+
`quoteTransferRemoteTo` was fixed to work without a default `Router._routers` enrollment by adding a target-router-aware gas quote helper. `GasRouter._setDestinationGas` was made virtual and overridden in MultiCollateral to accept MC-enrolled-only domains, keeping the existing `setDestinationGas` function selector working for all domain types. Authorization checks were deduplicated into `_requireAuthorizedRouter`. SDK EvmWarpRouteReader was updated to include MC-enrolled domains when reading destination gas.

solidity/contracts/client/GasRouter.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ abstract contract GasRouter is Router {
7878
StandardHookMetadata.overrideGasLimit(destinationGas[_destination]);
7979
}
8080

81-
function _setDestinationGas(uint32 domain, uint256 gas) internal {
81+
function _setDestinationGas(uint32 domain, uint256 gas) internal virtual {
8282
require(
8383
_routers.contains(uint256(domain)),
8484
_domainNotFoundError(domain)

solidity/multicollateral/contracts/MultiCollateral.sol

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
4343
using TypeCasts for bytes32;
4444
using SafeERC20 for IERC20;
4545
using EnumerableSet for EnumerableSet.Bytes32Set;
46+
using EnumerableSet for EnumerableSet.UintSet;
4647

4748
// ============ Events ============
4849

@@ -55,6 +56,10 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
5556
/// enrolled remote router). Local routers use localDomain as key.
5657
mapping(uint32 => EnumerableSet.Bytes32Set) private _enrolledRouters;
5758

59+
/// @notice Tracks which domains have at least one MC-enrolled router,
60+
/// enabling on-chain enumeration for the SDK reader.
61+
EnumerableSet.UintSet private _enrolledDomains;
62+
5863
// ============ Constructor ============
5964

6065
constructor(
@@ -73,6 +78,7 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
7378
require(_domains.length == _routers.length, "MC: length mismatch");
7479
for (uint256 i = 0; i < _domains.length; i++) {
7580
if (_enrolledRouters[_domains[i]].add(_routers[i])) {
81+
_enrolledDomains.add(uint256(_domains[i]));
7682
emit RouterEnrolled(_domains[i], _routers[i]);
7783
}
7884
}
@@ -85,6 +91,9 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
8591
require(_domains.length == _routers.length, "MC: length mismatch");
8692
for (uint256 i = 0; i < _domains.length; i++) {
8793
if (_enrolledRouters[_domains[i]].remove(_routers[i])) {
94+
if (_enrolledRouters[_domains[i]].length() == 0) {
95+
_enrolledDomains.remove(uint256(_domains[i]));
96+
}
8897
emit RouterUnenrolled(_domains[i], _routers[i]);
8998
}
9099
}
@@ -105,6 +114,50 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
105114
return _enrolledRouters[_domain].values();
106115
}
107116

117+
/// @notice Returns all domains that have at least one MC-enrolled router.
118+
function getEnrolledDomains()
119+
external
120+
view
121+
returns (uint32[] memory domains)
122+
{
123+
uint256 len = _enrolledDomains.length();
124+
domains = new uint32[](len);
125+
for (uint256 i = 0; i < len; i++) {
126+
domains[i] = uint32(_enrolledDomains.at(i));
127+
}
128+
}
129+
130+
// ============ Destination Gas Override ============
131+
132+
/// @dev Overrides GasRouter._setDestinationGas to also accept MC-enrolled
133+
/// domains (not just default Router._routers). Excludes localDomain since
134+
/// same-chain transfers skip mailbox dispatch.
135+
function _setDestinationGas(uint32 domain, uint256 gas) internal override {
136+
require(domain != localDomain, "MC: no gas for local domain");
137+
require(
138+
routers(domain) != bytes32(0) ||
139+
_enrolledRouters[domain].length() > 0,
140+
"MC: domain has no routers"
141+
);
142+
destinationGas[domain] = gas;
143+
emit GasSet(domain, gas);
144+
}
145+
146+
// ============ Internal Helpers ============
147+
148+
/// @dev Reverts unless `_router` is enrolled for `_domain` (either via the
149+
/// standard Router._routers map or via the MC-specific _enrolledRouters set).
150+
function _requireAuthorizedRouter(
151+
uint32 _domain,
152+
bytes32 _router
153+
) internal view {
154+
require(
155+
_isRemoteRouter(_domain, _router) ||
156+
_enrolledRouters[_domain].contains(_router),
157+
"MC: unauthorized router"
158+
);
159+
}
160+
108161
// ============ Handle Override ============
109162

110163
/// @dev Overrides `Router.handle` from core (`client/Router.sol`) via
@@ -119,11 +172,7 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
119172
) external payable override {
120173
if (msg.sender == address(mailbox)) {
121174
// Cross-chain via mailbox: sender must be enrolled
122-
require(
123-
_isRemoteRouter(_origin, _sender) ||
124-
_enrolledRouters[_origin].contains(_sender),
125-
"MC: unauthorized router"
126-
);
175+
_requireAuthorizedRouter(_origin, _sender);
127176
} else {
128177
// Same-chain direct call: caller must be an enrolled router
129178
require(
@@ -197,11 +246,12 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
197246
// Same-domain transferRemoteTo calls handle() directly and does not dispatch
198247
// through mailbox hooks, so do not charge hook fees in that path.
199248
if (_feeHook != address(0) && _destination != localDomain) {
200-
uint256 hookFee = _quoteGasPayment(
249+
uint256 hookFee = _quoteGasPaymentTo(
201250
_destination,
202251
_recipient,
203-
_amount,
204-
_token
252+
_outboundAmount(_amount),
253+
_token,
254+
_targetRouter
205255
);
206256
if (hookFee > 0) {
207257
if (_token != address(this)) {
@@ -263,12 +313,10 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
263313
uint256 _amount,
264314
bytes32 _targetRouter
265315
) public payable returns (bytes32 messageId) {
266-
require(
267-
_isRemoteRouter(_destination, _targetRouter) ||
268-
_enrolledRouters[_destination].contains(_targetRouter),
269-
"MC: unauthorized router"
270-
);
316+
_requireAuthorizedRouter(_destination, _targetRouter);
271317
if (_destination == localDomain) {
318+
// Local transfers call handle() directly without mailbox dispatch,
319+
// so any msg.value would be stuck in this contract permanently.
272320
require(msg.value == 0, "MC: local transfer no msg.value");
273321
}
274322

@@ -317,17 +365,26 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
317365
uint256 _amount,
318366
bytes32 _targetRouter
319367
) external view override returns (Quote[] memory quotes) {
368+
_requireAuthorizedRouter(_destination, _targetRouter);
369+
if (_destination == localDomain) {
370+
require(
371+
_targetRouter.bytes32ToAddress().code.length > 0,
372+
"MC: target router not contract"
373+
);
374+
}
375+
320376
quotes = new Quote[](3);
321377

322378
// Same-domain: handle() called directly, no interchain gas
323379
uint256 gasQuote = 0;
324380
address _feeToken = feeToken();
325381
if (_destination != localDomain) {
326-
gasQuote = _quoteGasPayment(
382+
gasQuote = _quoteGasPaymentTo(
327383
_destination,
328384
_recipient,
329385
_outboundAmount(_amount),
330-
_feeToken
386+
_feeToken,
387+
_targetRouter
331388
);
332389
}
333390
quotes[0] = Quote({token: _feeToken, amount: gasQuote});
@@ -346,4 +403,23 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
346403
amount: _externalFeeAmount(_destination, _recipient, _amount)
347404
});
348405
}
406+
407+
/// @dev Target-router-aware gas quote helper. Avoids Router._mustHaveRemoteRouter().
408+
/// Caller must validate `_targetRouter` is authorized for `_destination`.
409+
function _quoteGasPaymentTo(
410+
uint32 _destination,
411+
bytes32 _recipient,
412+
uint256 _amount,
413+
address _feeToken,
414+
bytes32 _targetRouter
415+
) internal view returns (uint256) {
416+
return
417+
mailbox.quoteDispatch(
418+
_destination,
419+
_targetRouter,
420+
TokenMessage.format(_recipient, _amount),
421+
_generateHookMetadata(_destination, _feeToken),
422+
IPostDispatchHook(address(hook))
423+
);
424+
}
349425
}

0 commit comments

Comments
 (0)