@hyperlane-xyz/multicollateral 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/contracts/{MultiCollateral.sol → CrossCollateralRouter.sol} +57 -48
  2. package/contracts/{MultiCollateralRoutingFee.sol → CrossCollateralRoutingFee.sol} +9 -6
  3. package/contracts/TokenBridgeOft.sol +338 -0
  4. package/contracts/interfaces/{IMultiCollateralFee.sol → ICrossCollateralFee.sol} +1 -1
  5. package/contracts/interfaces/layerzero/IOFT.sol +85 -0
  6. package/dist/typechain/{MultiCollateral.d.ts → CrossCollateralRouter.d.ts} +87 -87
  7. package/dist/typechain/CrossCollateralRouter.d.ts.map +1 -0
  8. package/dist/typechain/CrossCollateralRouter.js +2 -0
  9. package/dist/typechain/CrossCollateralRouter.js.map +1 -0
  10. package/dist/typechain/{MultiCollateralRoutingFee.d.ts → CrossCollateralRoutingFee.d.ts} +13 -13
  11. package/dist/typechain/{MultiCollateralRoutingFee.d.ts.map → CrossCollateralRoutingFee.d.ts.map} +1 -1
  12. package/dist/typechain/CrossCollateralRoutingFee.js +2 -0
  13. package/dist/typechain/CrossCollateralRoutingFee.js.map +1 -0
  14. package/dist/typechain/TokenBridgeOft.d.ts +293 -0
  15. package/dist/typechain/TokenBridgeOft.d.ts.map +1 -0
  16. package/dist/typechain/TokenBridgeOft.js +2 -0
  17. package/dist/typechain/TokenBridgeOft.js.map +1 -0
  18. package/dist/typechain/factories/{MultiCollateral__factory.d.ts → CrossCollateralRouter__factory.d.ts} +88 -88
  19. package/dist/typechain/factories/{MultiCollateral__factory.d.ts.map → CrossCollateralRouter__factory.d.ts.map} +1 -1
  20. package/dist/typechain/factories/{MultiCollateral__factory.js → CrossCollateralRouter__factory.js} +99 -99
  21. package/dist/typechain/factories/CrossCollateralRouter__factory.js.map +1 -0
  22. package/dist/typechain/factories/{MultiCollateralRoutingFee__factory.d.ts → CrossCollateralRoutingFee__factory.d.ts} +12 -12
  23. package/dist/typechain/factories/{MultiCollateralRoutingFee__factory.d.ts.map → CrossCollateralRoutingFee__factory.d.ts.map} +1 -1
  24. package/dist/typechain/factories/{MultiCollateralRoutingFee__factory.js → CrossCollateralRoutingFee__factory.js} +6 -6
  25. package/dist/typechain/factories/{MultiCollateralRoutingFee__factory.js.map → CrossCollateralRoutingFee__factory.js.map} +1 -1
  26. package/dist/typechain/factories/TokenBridgeOft__factory.d.ts +312 -0
  27. package/dist/typechain/factories/TokenBridgeOft__factory.d.ts.map +1 -0
  28. package/dist/typechain/factories/TokenBridgeOft__factory.js +417 -0
  29. package/dist/typechain/factories/TokenBridgeOft__factory.js.map +1 -0
  30. package/dist/typechain/factories/index.d.ts +3 -2
  31. package/dist/typechain/factories/index.d.ts.map +1 -1
  32. package/dist/typechain/factories/index.js +3 -2
  33. package/dist/typechain/factories/index.js.map +1 -1
  34. package/dist/typechain/index.d.ts +6 -4
  35. package/dist/typechain/index.d.ts.map +1 -1
  36. package/dist/typechain/index.js +3 -2
  37. package/dist/typechain/index.js.map +1 -1
  38. package/package.json +7 -6
  39. package/dist/typechain/MultiCollateral.d.ts.map +0 -1
  40. package/dist/typechain/MultiCollateral.js +0 -2
  41. package/dist/typechain/MultiCollateral.js.map +0 -1
  42. package/dist/typechain/MultiCollateralRoutingFee.js +0 -2
  43. package/dist/typechain/MultiCollateralRoutingFee.js.map +0 -1
  44. package/dist/typechain/factories/MultiCollateral__factory.js.map +0 -1
@@ -24,21 +24,24 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol
24
24
  import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
25
25
 
26
26
  // ============ Local Imports ============
27
- import {IMultiCollateralFee} from "./interfaces/IMultiCollateralFee.sol";
27
+ import {ICrossCollateralFee} from "./interfaces/ICrossCollateralFee.sol";
28
28
 
29
29
  /**
30
- * @title MultiCollateral
30
+ * @title CrossCollateralRouter
31
31
  * @notice Multi-router collateral: direct 1-message atomic transfers between
32
32
  * collateral routers, both cross-chain and same-chain.
33
33
  * @dev Extends HypERC20Collateral. Each deployed instance holds collateral for
34
- * one ERC20. Enrolled routers are other MultiCollateral instances (same or
34
+ * one ERC20. Enrolled routers are other CrossCollateralRouter instances (same or
35
35
  * different token) that this instance trusts to send/receive transfers.
36
+ * CrossCollateralRouter assumes standard ERC20 behavior with exact transfer
37
+ * amounts. Rebasing tokens, fee-on-transfer tokens, and ERC777 tokens are not
38
+ * supported due to exact-amount accounting in transfer/handle flows.
36
39
  *
37
40
  * Overrides:
38
41
  * - handle(): accepts messages from the mailbox (cross-chain) or directly
39
42
  * from enrolled routers on the same chain.
40
43
  */
41
- contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
44
+ contract CrossCollateralRouter is HypERC20Collateral, ICrossCollateralFee {
42
45
  using TypeCasts for address;
43
46
  using TypeCasts for bytes32;
44
47
  using SafeERC20 for IERC20;
@@ -47,18 +50,24 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
47
50
 
48
51
  // ============ Events ============
49
52
 
50
- event RouterEnrolled(uint32 indexed domain, bytes32 indexed router);
51
- event RouterUnenrolled(uint32 indexed domain, bytes32 indexed router);
53
+ event CrossCollateralRouterEnrolled(
54
+ uint32 indexed domain,
55
+ bytes32 indexed router
56
+ );
57
+ event CrossCollateralRouterUnenrolled(
58
+ uint32 indexed domain,
59
+ bytes32 indexed router
60
+ );
52
61
 
53
62
  // ============ Storage ============
54
63
 
55
64
  /// @notice Additional enrolled routers by domain (beyond the standard
56
65
  /// enrolled remote router). Local routers use localDomain as key.
57
- mapping(uint32 => EnumerableSet.Bytes32Set) private _enrolledRouters;
66
+ mapping(uint32 => EnumerableSet.Bytes32Set) private _crossCollateralRouters;
58
67
 
59
- /// @notice Tracks which domains have at least one MC-enrolled router,
68
+ /// @notice Tracks which domains have at least one CrossCollateral-enrolled router,
60
69
  /// enabling on-chain enumeration for the SDK reader.
61
- EnumerableSet.UintSet private _enrolledDomains;
70
+ EnumerableSet.UintSet private _crossCollateralDomains;
62
71
 
63
72
  // ============ Constructor ============
64
73
 
@@ -71,73 +80,73 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
71
80
 
72
81
  // ============ Router Management (onlyOwner) ============
73
82
 
74
- function enrollRouters(
83
+ function enrollCrossCollateralRouters(
75
84
  uint32[] calldata _domains,
76
85
  bytes32[] calldata _routers
77
86
  ) external onlyOwner {
78
- require(_domains.length == _routers.length, "MC: length mismatch");
87
+ require(_domains.length == _routers.length, "CCR: length mismatch");
79
88
  for (uint256 i = 0; i < _domains.length; i++) {
80
- if (_enrolledRouters[_domains[i]].add(_routers[i])) {
81
- _enrolledDomains.add(uint256(_domains[i]));
82
- emit RouterEnrolled(_domains[i], _routers[i]);
89
+ if (_crossCollateralRouters[_domains[i]].add(_routers[i])) {
90
+ _crossCollateralDomains.add(uint256(_domains[i]));
91
+ emit CrossCollateralRouterEnrolled(_domains[i], _routers[i]);
83
92
  }
84
93
  }
85
94
  }
86
95
 
87
- function unenrollRouters(
96
+ function unenrollCrossCollateralRouters(
88
97
  uint32[] calldata _domains,
89
98
  bytes32[] calldata _routers
90
99
  ) external onlyOwner {
91
- require(_domains.length == _routers.length, "MC: length mismatch");
100
+ require(_domains.length == _routers.length, "CCR: length mismatch");
92
101
  for (uint256 i = 0; i < _domains.length; i++) {
93
- if (_enrolledRouters[_domains[i]].remove(_routers[i])) {
94
- if (_enrolledRouters[_domains[i]].length() == 0) {
95
- _enrolledDomains.remove(uint256(_domains[i]));
102
+ if (_crossCollateralRouters[_domains[i]].remove(_routers[i])) {
103
+ if (_crossCollateralRouters[_domains[i]].length() == 0) {
104
+ _crossCollateralDomains.remove(uint256(_domains[i]));
96
105
  }
97
- emit RouterUnenrolled(_domains[i], _routers[i]);
106
+ emit CrossCollateralRouterUnenrolled(_domains[i], _routers[i]);
98
107
  }
99
108
  }
100
109
  }
101
110
 
102
- function enrolledRouters(
111
+ function crossCollateralRouters(
103
112
  uint32 _domain,
104
113
  bytes32 _router
105
114
  ) external view returns (bool) {
106
- return _enrolledRouters[_domain].contains(_router);
115
+ return _crossCollateralRouters[_domain].contains(_router);
107
116
  }
108
117
 
109
118
  // ============ Enumeration ============
110
119
 
111
- function getEnrolledRouters(
120
+ function getCrossCollateralRouters(
112
121
  uint32 _domain
113
122
  ) external view returns (bytes32[] memory) {
114
- return _enrolledRouters[_domain].values();
123
+ return _crossCollateralRouters[_domain].values();
115
124
  }
116
125
 
117
- /// @notice Returns all domains that have at least one MC-enrolled router.
118
- function getEnrolledDomains()
126
+ /// @notice Returns all domains that have at least one CrossCollateral-enrolled router.
127
+ function getCrossCollateralDomains()
119
128
  external
120
129
  view
121
130
  returns (uint32[] memory domains)
122
131
  {
123
- uint256 len = _enrolledDomains.length();
132
+ uint256 len = _crossCollateralDomains.length();
124
133
  domains = new uint32[](len);
125
134
  for (uint256 i = 0; i < len; i++) {
126
- domains[i] = uint32(_enrolledDomains.at(i));
135
+ domains[i] = uint32(_crossCollateralDomains.at(i));
127
136
  }
128
137
  }
129
138
 
130
139
  // ============ Destination Gas Override ============
131
140
 
132
- /// @dev Overrides GasRouter._setDestinationGas to also accept MC-enrolled
141
+ /// @dev Overrides GasRouter._setDestinationGas to also accept CrossCollateral-enrolled
133
142
  /// domains (not just default Router._routers). Excludes localDomain since
134
143
  /// same-chain transfers skip mailbox dispatch.
135
144
  function _setDestinationGas(uint32 domain, uint256 gas) internal override {
136
- require(domain != localDomain, "MC: no gas for local domain");
145
+ require(domain != localDomain, "CCR: no gas for local domain");
137
146
  require(
138
147
  routers(domain) != bytes32(0) ||
139
- _enrolledRouters[domain].length() > 0,
140
- "MC: domain has no routers"
148
+ _crossCollateralRouters[domain].length() > 0,
149
+ "CCR: domain has no routers"
141
150
  );
142
151
  destinationGas[domain] = gas;
143
152
  emit GasSet(domain, gas);
@@ -146,15 +155,15 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
146
155
  // ============ Internal Helpers ============
147
156
 
148
157
  /// @dev Reverts unless `_router` is enrolled for `_domain` (either via the
149
- /// standard Router._routers map or via the MC-specific _enrolledRouters set).
158
+ /// standard Router._routers map or via the CrossCollateral-specific _crossCollateralRouters set).
150
159
  function _requireAuthorizedRouter(
151
160
  uint32 _domain,
152
161
  bytes32 _router
153
162
  ) internal view {
154
163
  require(
155
164
  _isRemoteRouter(_domain, _router) ||
156
- _enrolledRouters[_domain].contains(_router),
157
- "MC: unauthorized router"
165
+ _crossCollateralRouters[_domain].contains(_router),
166
+ "CCR: unauthorized router"
158
167
  );
159
168
  }
160
169
 
@@ -176,10 +185,10 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
176
185
  } else {
177
186
  // Same-chain direct call: caller must be an enrolled router
178
187
  require(
179
- _enrolledRouters[localDomain].contains(
188
+ _crossCollateralRouters[localDomain].contains(
180
189
  TypeCasts.addressToBytes32(msg.sender)
181
190
  ),
182
- "MC: unauthorized router"
191
+ "CCR: unauthorized router"
183
192
  );
184
193
  }
185
194
  _handle(_origin, _sender, _message);
@@ -187,7 +196,7 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
187
196
 
188
197
  // ============ Per-Router Fee Lookup ============
189
198
  // Mirrors TokenRouter._feeRecipientAndAmount but routes through
190
- // IMultiCollateralFee.quoteTransferRemoteTo (which includes _targetRouter)
199
+ // ICrossCollateralFee.quoteTransferRemoteTo (which includes _targetRouter)
191
200
  // instead of ITokenFee.quoteTransferRemote (destination-only).
192
201
 
193
202
  function _feeRecipientAndAmountForRouter(
@@ -200,7 +209,7 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
200
209
  if (_feeRecipient == address(0)) return (_feeRecipient, 0);
201
210
 
202
211
  // Only difference from base: quoteTransferRemoteTo with _targetRouter
203
- Quote[] memory quotes = IMultiCollateralFee(_feeRecipient)
212
+ Quote[] memory quotes = ICrossCollateralFee(_feeRecipient)
204
213
  .quoteTransferRemoteTo(
205
214
  _destination,
206
215
  _recipient,
@@ -211,7 +220,7 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
211
220
 
212
221
  require(
213
222
  quotes.length == 1 && quotes[0].token == token(),
214
- "MC: fee must match token"
223
+ "CCR: fee must match token"
215
224
  );
216
225
  feeAmount = quotes[0].amount;
217
226
  }
@@ -281,10 +290,10 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
281
290
  /**
282
291
  * @notice Transfers tokens to the primary enrolled router for `_destination`.
283
292
  * @dev Uses the enrolled primary remote router for `_destination` and routes through
284
- * router-aware fee lookup (`IMultiCollateralFee`) via `transferRemoteTo`.
293
+ * router-aware fee lookup (`ICrossCollateralFee`) via `transferRemoteTo`.
285
294
  * @dev This override is required because TokenRouter's `_feeRecipientAndAmount`
286
295
  * is non-virtual and hardcodes `ITokenFee`. Delegating through
287
- * `transferRemoteTo` keeps both transfer paths on `IMultiCollateralFee`.
296
+ * `transferRemoteTo` keeps both transfer paths on `ICrossCollateralFee`.
288
297
  */
289
298
  function transferRemote(
290
299
  uint32 _destination,
@@ -317,7 +326,7 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
317
326
  if (_destination == localDomain) {
318
327
  // Local transfers call handle() directly without mailbox dispatch,
319
328
  // so any msg.value would be stuck in this contract permanently.
320
- require(msg.value == 0, "MC: local transfer no msg.value");
329
+ require(msg.value == 0, "CCR: local transfer no msg.value");
321
330
  }
322
331
 
323
332
  (, uint256 remainingValue) = _calculateFeesAndChargeForRouter(
@@ -334,8 +343,8 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
334
343
  if (_destination == localDomain) {
335
344
  // Same-domain: call target router's handle directly
336
345
  address target = _targetRouter.bytes32ToAddress();
337
- require(target.code.length > 0, "MC: target router not contract");
338
- MultiCollateral(target).handle(
346
+ require(target.code.length > 0, "CCR: target router not contract");
347
+ CrossCollateralRouter(target).handle(
339
348
  localDomain,
340
349
  TypeCasts.addressToBytes32(address(this)),
341
350
  tokenMsg
@@ -358,18 +367,18 @@ contract MultiCollateral is HypERC20Collateral, IMultiCollateralFee {
358
367
  // Differences: (1) router-aware fee lookup, (2) same-domain returns 0 gas
359
368
  // since handle() is called directly without mailbox dispatch.
360
369
 
361
- /// @inheritdoc IMultiCollateralFee
370
+ /// @inheritdoc ICrossCollateralFee
362
371
  function quoteTransferRemoteTo(
363
372
  uint32 _destination,
364
373
  bytes32 _recipient,
365
374
  uint256 _amount,
366
375
  bytes32 _targetRouter
367
- ) external view override returns (Quote[] memory quotes) {
376
+ ) public view override returns (Quote[] memory quotes) {
368
377
  _requireAuthorizedRouter(_destination, _targetRouter);
369
378
  if (_destination == localDomain) {
370
379
  require(
371
380
  _targetRouter.bytes32ToAddress().code.length > 0,
372
- "MC: target router not contract"
381
+ "CCR: target router not contract"
373
382
  );
374
383
  }
375
384
 
@@ -1,18 +1,21 @@
1
1
  // SPDX-License-Identifier: MIT OR Apache-2.0
2
2
  pragma solidity >=0.8.0;
3
3
 
4
- import {IMultiCollateralFee} from "./interfaces/IMultiCollateralFee.sol";
4
+ import {ICrossCollateralFee} from "./interfaces/ICrossCollateralFee.sol";
5
5
  import {ITokenFee, Quote} from "@hyperlane-xyz/core/interfaces/ITokenBridge.sol";
6
6
  import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
7
7
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
8
8
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
9
9
 
10
10
  /**
11
- * @title MultiCollateralRoutingFee
11
+ * @title CrossCollateralRoutingFee
12
12
  * @notice Routes fee lookups by destination + target router. Delegates to
13
13
  * existing ITokenFee (3-param) fee contracts (LinearFee, ProgressiveFee, etc.).
14
+ * @dev This contract assumes standard ERC20 behavior for fee balances and
15
+ * transfers. Rebasing, fee-on-transfer, and ERC777 tokens are not supported
16
+ * for deterministic fee accounting.
14
17
  */
15
- contract MultiCollateralRoutingFee is IMultiCollateralFee, ITokenFee, Ownable {
18
+ contract CrossCollateralRoutingFee is ICrossCollateralFee, ITokenFee, Ownable {
16
19
  using SafeERC20 for IERC20;
17
20
 
18
21
  /// @notice Sentinel key for destination-level default fee contracts.
@@ -34,7 +37,7 @@ contract MultiCollateralRoutingFee is IMultiCollateralFee, ITokenFee, Ownable {
34
37
  _transferOwnership(_owner);
35
38
  }
36
39
 
37
- function setRouterFeeContracts(
40
+ function setCrossCollateralRouterFeeContracts(
38
41
  uint32[] calldata destinations,
39
42
  bytes32[] calldata targetRouters,
40
43
  address[] calldata _feeContracts
@@ -42,7 +45,7 @@ contract MultiCollateralRoutingFee is IMultiCollateralFee, ITokenFee, Ownable {
42
45
  require(
43
46
  destinations.length == targetRouters.length &&
44
47
  destinations.length == _feeContracts.length,
45
- "MCF: length mismatch"
48
+ "CCRF: length mismatch"
46
49
  );
47
50
 
48
51
  for (uint256 i = 0; i < destinations.length; i++) {
@@ -79,7 +82,7 @@ contract MultiCollateralRoutingFee is IMultiCollateralFee, ITokenFee, Ownable {
79
82
  }
80
83
 
81
84
  /**
82
- * @inheritdoc IMultiCollateralFee
85
+ * @inheritdoc ICrossCollateralFee
83
86
  * @dev Routes: specific router → destination default (DEFAULT_ROUTER).
84
87
  */
85
88
  function quoteTransferRemoteTo(
@@ -0,0 +1,338 @@
1
+ // SPDX-License-Identifier: MIT OR Apache-2.0
2
+ pragma solidity >=0.8.0;
3
+
4
+ import {ITokenBridge, ITokenFee, Quote} from "@hyperlane-xyz/core/interfaces/ITokenBridge.sol";
5
+ import {IOFT, SendParam, MessagingFee, MessagingReceipt, OFTReceipt} from "./interfaces/layerzero/IOFT.sol";
6
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7
+ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
8
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
9
+ import {EnumerableMap} from "@openzeppelin/contracts/utils/structs/EnumerableMap.sol";
10
+ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
11
+ import {Address} from "@openzeppelin/contracts/utils/Address.sol";
12
+ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
13
+ import {PackageVersioned} from "@hyperlane-xyz/core/PackageVersioned.sol";
14
+
15
+ /**
16
+ * @title TokenBridgeOft
17
+ * @notice Warp route adapter for LayerZero OFT (Omnichain Fungible Token) contracts.
18
+ *
19
+ * @dev This contract implements ITokenBridge directly with Ownable for admin.
20
+ * It does NOT use Hyperlane messaging for transfers. Instead, transferRemote bridges
21
+ * via OFT.send() and there is no inbound message handling.
22
+ *
23
+ * @dev Dust and decimal handling:
24
+ * OFTs use "sharedDecimals" (typically 6) as a wire format, regardless of the
25
+ * token's local decimals. When localDecimals > sharedDecimals, the OFT truncates
26
+ * sub-sharedDecimals precision ("dust") via _removeDust() before sending. This
27
+ * contract stores the decimalConversionRate (10^(localDecimals - sharedDecimals))
28
+ * as an immutable and uses it to:
29
+ * 1. Round grossAmount UP to the next dust-free boundary after fee inversion,
30
+ * preventing SlippageExceeded reverts from the OFT.
31
+ * 2. Round minAmountLD DOWN to a dust-free value, since the OFT cannot deliver
32
+ * sub-dust precision anyway.
33
+ *
34
+ * Supports all OFT patterns:
35
+ * - Native OFT (burn/mint, approvalRequired=false)
36
+ * - OFTAdapter (lock/unlock, approvalRequired=true)
37
+ * - OFTWrapper (Paxos-style burn/mint, approvalRequired=false)
38
+ *
39
+ * Token support:
40
+ * - Fee-on-transfer tokens: NOT supported — amount mismatches between
41
+ * safeTransferFrom and OFT.send will cause failures or loss.
42
+ * - Rebasing tokens: NOT supported — amounts may diverge across chains.
43
+ * - ERC-777: NOT explicitly supported — hook reentrancy not guarded.
44
+ */
45
+ contract TokenBridgeOft is ITokenBridge, Ownable, PackageVersioned {
46
+ using SafeERC20 for IERC20;
47
+ using EnumerableMap for EnumerableMap.UintToUintMap;
48
+
49
+ // ============ Errors ============
50
+
51
+ error LzEidNotConfigured(uint32 hyperlaneDomain);
52
+
53
+ // ============ Events ============
54
+
55
+ event SentTransferRemote(
56
+ uint32 indexed destination,
57
+ bytes32 indexed recipient,
58
+ uint256 amount
59
+ );
60
+ event DomainAdded(uint32 indexed hyperlaneDomain, uint32 lzEid);
61
+ event DomainRemoved(uint32 indexed hyperlaneDomain);
62
+ event ExtraOptionsSet(bytes extraOptions);
63
+
64
+ // ============ Storage ============
65
+
66
+ /// @notice The LayerZero OFT contract to bridge through
67
+ IOFT public immutable oft;
68
+
69
+ /// @notice The underlying ERC20 token
70
+ IERC20 public immutable wrappedToken;
71
+
72
+ /// @notice 10^(localDecimals - sharedDecimals). Amounts are only meaningful
73
+ /// at multiples of this value; sub-dust precision is truncated by the OFT.
74
+ /// Equals 1 when localDecimals == sharedDecimals (no dust).
75
+ uint256 public immutable decimalConversionRate;
76
+
77
+ /// @notice Enumerable mapping from Hyperlane domain ID to LayerZero endpoint ID
78
+ EnumerableMap.UintToUintMap private _domainToLzEid;
79
+
80
+ /// @notice Configurable LayerZero extra options (e.g., destination gas limits)
81
+ bytes public extraOptions;
82
+
83
+ // ============ Constructor ============
84
+
85
+ /**
86
+ * @param _oft Address of the OFT / OFTAdapter / OFTWrapper contract
87
+ * @param _owner Initial owner of the contract
88
+ */
89
+ constructor(address _oft, address _owner) {
90
+ require(_oft != address(0), "TokenBridgeOft: zero OFT address");
91
+
92
+ oft = IOFT(_oft);
93
+ address _token = IOFT(_oft).token();
94
+ wrappedToken = IERC20(_token);
95
+
96
+ uint8 localDecimals = IERC20Metadata(_token).decimals();
97
+ uint8 sharedDecimals = IOFT(_oft).sharedDecimals();
98
+ require(
99
+ localDecimals >= sharedDecimals,
100
+ "TokenBridgeOft: localDecimals < sharedDecimals"
101
+ );
102
+ decimalConversionRate = 10 ** (localDecimals - sharedDecimals);
103
+
104
+ _transferOwnership(_owner);
105
+ wrappedToken.safeApprove(address(oft), type(uint256).max);
106
+ }
107
+
108
+ // ============ ITokenBridge ============
109
+
110
+ /// @notice Returns the address of the underlying ERC20 token.
111
+ function token() public view returns (address) {
112
+ return address(wrappedToken);
113
+ }
114
+
115
+ /// @inheritdoc ITokenFee
116
+ function quoteTransferRemote(
117
+ uint32 _destination,
118
+ bytes32 _recipient,
119
+ uint256 _amount
120
+ ) external view override returns (Quote[] memory quotes) {
121
+ uint256 nativeFee = _quoteGasPayment(_destination, _recipient, _amount);
122
+ uint256 externalFee = _externalFeeAmount(
123
+ _destination,
124
+ _recipient,
125
+ _amount
126
+ );
127
+
128
+ quotes = new Quote[](2);
129
+ quotes[0] = Quote({token: address(0), amount: nativeFee});
130
+ quotes[1] = Quote({
131
+ token: address(wrappedToken),
132
+ amount: _amount + externalFee
133
+ });
134
+ }
135
+
136
+ /// @inheritdoc ITokenBridge
137
+ function transferRemote(
138
+ uint32 _destination,
139
+ bytes32 _recipient,
140
+ uint256 _amount
141
+ ) external payable override returns (bytes32 messageId) {
142
+ // 1. Calculate OFT fee and pull total from sender
143
+ uint256 externalFee = _externalFeeAmount(
144
+ _destination,
145
+ _recipient,
146
+ _amount
147
+ );
148
+ uint256 grossAmount = _amount + externalFee;
149
+ wrappedToken.safeTransferFrom(msg.sender, address(this), grossAmount);
150
+
151
+ // 2. Build OFT send params: send grossAmount, enforce dust-free _amount as minimum received.
152
+ SendParam memory sendParam = _buildSendParam(
153
+ _destination,
154
+ _recipient,
155
+ grossAmount,
156
+ _removeDust(_amount)
157
+ );
158
+
159
+ // 3. Quote native gas fee and send via OFT
160
+ uint256 nativeFee = _quoteGasPayment(_destination, _recipient, _amount);
161
+
162
+ MessagingFee memory msgFee = MessagingFee({
163
+ nativeFee: nativeFee,
164
+ lzTokenFee: 0
165
+ });
166
+
167
+ emit SentTransferRemote(_destination, _recipient, _amount);
168
+
169
+ (MessagingReceipt memory msgReceipt, ) = oft.send{value: nativeFee}(
170
+ sendParam,
171
+ msgFee,
172
+ msg.sender
173
+ );
174
+
175
+ // 4. Refund excess native value back to caller
176
+ uint256 excessNative = msg.value - nativeFee;
177
+ if (excessNative > 0) {
178
+ Address.sendValue(payable(msg.sender), excessNative);
179
+ }
180
+
181
+ return msgReceipt.guid;
182
+ }
183
+
184
+ // ============ Admin ============
185
+
186
+ function addDomain(
187
+ uint32 _hyperlaneDomain,
188
+ uint32 _lzEid
189
+ ) external onlyOwner {
190
+ require(_lzEid != 0, "TokenBridgeOft: zero LZ EID");
191
+ _domainToLzEid.set(uint256(_hyperlaneDomain), uint256(_lzEid));
192
+ emit DomainAdded(_hyperlaneDomain, _lzEid);
193
+ }
194
+
195
+ function removeDomain(uint32 _hyperlaneDomain) external onlyOwner {
196
+ bool removed = _domainToLzEid.remove(uint256(_hyperlaneDomain));
197
+ require(removed, "TokenBridgeOft: domain not configured");
198
+ emit DomainRemoved(_hyperlaneDomain);
199
+ }
200
+
201
+ function setExtraOptions(bytes calldata _options) external onlyOwner {
202
+ extraOptions = _options;
203
+ emit ExtraOptionsSet(_options);
204
+ }
205
+
206
+ // ============ Views ============
207
+
208
+ /// @notice Look up the LZ endpoint ID for a given Hyperlane domain.
209
+ function hyperlaneDomainToLzEid(
210
+ uint32 _domain
211
+ ) external view returns (uint32) {
212
+ return _getLzEid(_domain);
213
+ }
214
+
215
+ /// @notice Returns all configured domain mappings as parallel arrays.
216
+ function getDomainMappings()
217
+ external
218
+ view
219
+ returns (uint32[] memory domains, uint32[] memory lzEids)
220
+ {
221
+ uint256 len = _domainToLzEid.length();
222
+ domains = new uint32[](len);
223
+ lzEids = new uint32[](len);
224
+ for (uint256 i = 0; i < len; i++) {
225
+ (uint256 domain, uint256 eid) = _domainToLzEid.at(i);
226
+ domains[i] = uint32(domain);
227
+ lzEids[i] = uint32(eid);
228
+ }
229
+ }
230
+
231
+ // ============ Internal ============
232
+
233
+ function _getLzEid(uint32 _domain) internal view returns (uint32) {
234
+ (bool exists, uint256 eid) = _domainToLzEid.tryGet(uint256(_domain));
235
+ if (!exists) revert LzEidNotConfigured(_domain);
236
+ return uint32(eid);
237
+ }
238
+
239
+ function _buildSendParam(
240
+ uint32 _destination,
241
+ bytes32 _recipient,
242
+ uint256 _amount,
243
+ uint256 _minAmountLD
244
+ ) internal view returns (SendParam memory) {
245
+ return
246
+ SendParam({
247
+ dstEid: _getLzEid(_destination),
248
+ to: _recipient,
249
+ amountLD: _amount,
250
+ minAmountLD: _minAmountLD,
251
+ extraOptions: extraOptions,
252
+ composeMsg: "",
253
+ oftCmd: ""
254
+ });
255
+ }
256
+
257
+ /// @dev Return the LZ native fee for sending via OFT.
258
+ function _quoteGasPayment(
259
+ uint32 _destination,
260
+ bytes32 _recipient,
261
+ uint256 _amount
262
+ ) internal view returns (uint256) {
263
+ SendParam memory sendParam = _buildSendParam(
264
+ _destination,
265
+ _recipient,
266
+ _amount,
267
+ 0
268
+ );
269
+ MessagingFee memory msgFee = oft.quoteSend(sendParam, false);
270
+ return msgFee.nativeFee;
271
+ }
272
+
273
+ /// @dev Return the OFT token fee (difference between gross input and net output).
274
+ function _externalFeeAmount(
275
+ uint32 _destination,
276
+ bytes32 _recipient,
277
+ uint256 _amount
278
+ ) internal view returns (uint256) {
279
+ return _grossOftAmount(_destination, _recipient, _amount) - _amount;
280
+ }
281
+
282
+ /**
283
+ * @dev Analytically invert the OFT fee to compute the gross input amount
284
+ * such that the recipient receives at least _amount after OFT deductions.
285
+ *
286
+ * OFT fees are linear (percentage-based), so the inversion is:
287
+ * grossAmount = ceil(_amount * amountSentLD / amountReceivedLD)
288
+ *
289
+ * After inversion, grossAmount is rounded UP to the next dust-free boundary.
290
+ * This is necessary because OFTs internally call _removeDust() which truncates
291
+ * sub-sharedDecimals precision. Without this rounding, the truncated gross
292
+ * amount after fee deduction can fall below _amount, causing SlippageExceeded.
293
+ *
294
+ * Example with 18 local / 6 shared decimals and 1% fee:
295
+ * _amount = 1e18, probe gives sent=1e18, received=0.99e18
296
+ * ceilDiv → 1010101010101010102 (has dust in last 12 digits)
297
+ * OFT would truncate to 1010101000000000000, then deduct 1% → 0.99999999e18 < 1e18
298
+ * Rounding up to 1010102000000000000 ensures post-fee amount >= 1e18
299
+ *
300
+ * If the OFT charges no fee (amountSentLD == amountReceivedLD), returns _amount
301
+ * rounded up to the nearest dust-free value (to handle dusty input amounts).
302
+ */
303
+ function _grossOftAmount(
304
+ uint32 _destination,
305
+ bytes32 _recipient,
306
+ uint256 _amount
307
+ ) internal view returns (uint256) {
308
+ SendParam memory probeParam = _buildSendParam(
309
+ _destination,
310
+ _recipient,
311
+ _amount,
312
+ 0
313
+ );
314
+ (, , OFTReceipt memory receipt) = oft.quoteOFT(probeParam);
315
+
316
+ uint256 sent = receipt.amountSentLD;
317
+ uint256 received = receipt.amountReceivedLD;
318
+
319
+ // No fee (or zero amount): return _amount rounded up to dust-free boundary
320
+ if (sent == received) return _roundUpDust(_amount);
321
+
322
+ // Analytical inversion, then round up to dust-free boundary
323
+ // If received == 0 (100% fee), Math.mulDiv reverts with division by zero
324
+ uint256 gross = Math.mulDiv(_amount, sent, received, Math.Rounding.Up);
325
+ return _roundUpDust(gross);
326
+ }
327
+
328
+ /// @dev Round `_amount` DOWN to the nearest dust-free value (mirrors OFT._removeDust).
329
+ function _removeDust(uint256 _amount) internal view returns (uint256) {
330
+ return (_amount / decimalConversionRate) * decimalConversionRate;
331
+ }
332
+
333
+ /// @dev Round `_amount` UP to the nearest dust-free value.
334
+ function _roundUpDust(uint256 _amount) internal view returns (uint256) {
335
+ uint256 rate = decimalConversionRate;
336
+ return ((_amount + rate - 1) / rate) * rate;
337
+ }
338
+ }
@@ -3,7 +3,7 @@ pragma solidity >=0.8.0;
3
3
 
4
4
  import {Quote} from "@hyperlane-xyz/core/interfaces/ITokenBridge.sol";
5
5
 
6
- interface IMultiCollateralFee {
6
+ interface ICrossCollateralFee {
7
7
  function quoteTransferRemoteTo(
8
8
  uint32 _destination,
9
9
  bytes32 _recipient,