@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.
- package/contracts/{MultiCollateral.sol → CrossCollateralRouter.sol} +57 -48
- package/contracts/{MultiCollateralRoutingFee.sol → CrossCollateralRoutingFee.sol} +9 -6
- package/contracts/TokenBridgeOft.sol +338 -0
- package/contracts/interfaces/{IMultiCollateralFee.sol → ICrossCollateralFee.sol} +1 -1
- package/contracts/interfaces/layerzero/IOFT.sol +85 -0
- package/dist/typechain/{MultiCollateral.d.ts → CrossCollateralRouter.d.ts} +87 -87
- package/dist/typechain/CrossCollateralRouter.d.ts.map +1 -0
- package/dist/typechain/CrossCollateralRouter.js +2 -0
- package/dist/typechain/CrossCollateralRouter.js.map +1 -0
- package/dist/typechain/{MultiCollateralRoutingFee.d.ts → CrossCollateralRoutingFee.d.ts} +13 -13
- package/dist/typechain/{MultiCollateralRoutingFee.d.ts.map → CrossCollateralRoutingFee.d.ts.map} +1 -1
- package/dist/typechain/CrossCollateralRoutingFee.js +2 -0
- package/dist/typechain/CrossCollateralRoutingFee.js.map +1 -0
- package/dist/typechain/TokenBridgeOft.d.ts +293 -0
- package/dist/typechain/TokenBridgeOft.d.ts.map +1 -0
- package/dist/typechain/TokenBridgeOft.js +2 -0
- package/dist/typechain/TokenBridgeOft.js.map +1 -0
- package/dist/typechain/factories/{MultiCollateral__factory.d.ts → CrossCollateralRouter__factory.d.ts} +88 -88
- package/dist/typechain/factories/{MultiCollateral__factory.d.ts.map → CrossCollateralRouter__factory.d.ts.map} +1 -1
- package/dist/typechain/factories/{MultiCollateral__factory.js → CrossCollateralRouter__factory.js} +99 -99
- package/dist/typechain/factories/CrossCollateralRouter__factory.js.map +1 -0
- package/dist/typechain/factories/{MultiCollateralRoutingFee__factory.d.ts → CrossCollateralRoutingFee__factory.d.ts} +12 -12
- package/dist/typechain/factories/{MultiCollateralRoutingFee__factory.d.ts.map → CrossCollateralRoutingFee__factory.d.ts.map} +1 -1
- package/dist/typechain/factories/{MultiCollateralRoutingFee__factory.js → CrossCollateralRoutingFee__factory.js} +6 -6
- package/dist/typechain/factories/{MultiCollateralRoutingFee__factory.js.map → CrossCollateralRoutingFee__factory.js.map} +1 -1
- package/dist/typechain/factories/TokenBridgeOft__factory.d.ts +312 -0
- package/dist/typechain/factories/TokenBridgeOft__factory.d.ts.map +1 -0
- package/dist/typechain/factories/TokenBridgeOft__factory.js +417 -0
- package/dist/typechain/factories/TokenBridgeOft__factory.js.map +1 -0
- package/dist/typechain/factories/index.d.ts +3 -2
- package/dist/typechain/factories/index.d.ts.map +1 -1
- package/dist/typechain/factories/index.js +3 -2
- package/dist/typechain/factories/index.js.map +1 -1
- package/dist/typechain/index.d.ts +6 -4
- package/dist/typechain/index.d.ts.map +1 -1
- package/dist/typechain/index.js +3 -2
- package/dist/typechain/index.js.map +1 -1
- package/package.json +7 -6
- package/dist/typechain/MultiCollateral.d.ts.map +0 -1
- package/dist/typechain/MultiCollateral.js +0 -2
- package/dist/typechain/MultiCollateral.js.map +0 -1
- package/dist/typechain/MultiCollateralRoutingFee.js +0 -2
- package/dist/typechain/MultiCollateralRoutingFee.js.map +0 -1
- 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 {
|
|
27
|
+
import {ICrossCollateralFee} from "./interfaces/ICrossCollateralFee.sol";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* @title
|
|
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
|
|
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
|
|
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
|
|
51
|
-
|
|
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
|
|
66
|
+
mapping(uint32 => EnumerableSet.Bytes32Set) private _crossCollateralRouters;
|
|
58
67
|
|
|
59
|
-
/// @notice Tracks which domains have at least one
|
|
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
|
|
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
|
|
83
|
+
function enrollCrossCollateralRouters(
|
|
75
84
|
uint32[] calldata _domains,
|
|
76
85
|
bytes32[] calldata _routers
|
|
77
86
|
) external onlyOwner {
|
|
78
|
-
require(_domains.length == _routers.length, "
|
|
87
|
+
require(_domains.length == _routers.length, "CCR: length mismatch");
|
|
79
88
|
for (uint256 i = 0; i < _domains.length; i++) {
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
emit
|
|
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
|
|
96
|
+
function unenrollCrossCollateralRouters(
|
|
88
97
|
uint32[] calldata _domains,
|
|
89
98
|
bytes32[] calldata _routers
|
|
90
99
|
) external onlyOwner {
|
|
91
|
-
require(_domains.length == _routers.length, "
|
|
100
|
+
require(_domains.length == _routers.length, "CCR: length mismatch");
|
|
92
101
|
for (uint256 i = 0; i < _domains.length; i++) {
|
|
93
|
-
if (
|
|
94
|
-
if (
|
|
95
|
-
|
|
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
|
|
106
|
+
emit CrossCollateralRouterUnenrolled(_domains[i], _routers[i]);
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
110
|
|
|
102
|
-
function
|
|
111
|
+
function crossCollateralRouters(
|
|
103
112
|
uint32 _domain,
|
|
104
113
|
bytes32 _router
|
|
105
114
|
) external view returns (bool) {
|
|
106
|
-
return
|
|
115
|
+
return _crossCollateralRouters[_domain].contains(_router);
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
// ============ Enumeration ============
|
|
110
119
|
|
|
111
|
-
function
|
|
120
|
+
function getCrossCollateralRouters(
|
|
112
121
|
uint32 _domain
|
|
113
122
|
) external view returns (bytes32[] memory) {
|
|
114
|
-
return
|
|
123
|
+
return _crossCollateralRouters[_domain].values();
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
/// @notice Returns all domains that have at least one
|
|
118
|
-
function
|
|
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 =
|
|
132
|
+
uint256 len = _crossCollateralDomains.length();
|
|
124
133
|
domains = new uint32[](len);
|
|
125
134
|
for (uint256 i = 0; i < len; i++) {
|
|
126
|
-
domains[i] = uint32(
|
|
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
|
|
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, "
|
|
145
|
+
require(domain != localDomain, "CCR: no gas for local domain");
|
|
137
146
|
require(
|
|
138
147
|
routers(domain) != bytes32(0) ||
|
|
139
|
-
|
|
140
|
-
"
|
|
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
|
|
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
|
-
|
|
157
|
-
"
|
|
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
|
-
|
|
188
|
+
_crossCollateralRouters[localDomain].contains(
|
|
180
189
|
TypeCasts.addressToBytes32(msg.sender)
|
|
181
190
|
),
|
|
182
|
-
"
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
"
|
|
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 (`
|
|
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 `
|
|
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, "
|
|
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, "
|
|
338
|
-
|
|
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
|
|
370
|
+
/// @inheritdoc ICrossCollateralFee
|
|
362
371
|
function quoteTransferRemoteTo(
|
|
363
372
|
uint32 _destination,
|
|
364
373
|
bytes32 _recipient,
|
|
365
374
|
uint256 _amount,
|
|
366
375
|
bytes32 _targetRouter
|
|
367
|
-
)
|
|
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
|
-
"
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
+
}
|