-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathL1Bridge.sol
More file actions
360 lines (321 loc) · 15.5 KB
/
Copy pathL1Bridge.sol
File metadata and controls
360 lines (321 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
// SPDX-License-Identifier: BSD-3-Clause-Clear
pragma solidity ^0.8.26;
import {L1Nodl} from "../L1Nodl.sol";
// Use local submodule paths instead of unavailable @zksync package imports
import {IMailbox} from "lib/era-contracts/l1-contracts/contracts/state-transition/chain-interfaces/IMailbox.sol";
import {
IBridgehub,
L2TransactionRequestDirect
} from "lib/era-contracts/l1-contracts/contracts/bridgehub/IBridgehub.sol";
import {L2Message, TxStatus} from "lib/era-contracts/l1-contracts/contracts/common/Messaging.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {UnsafeBytes} from "lib/era-contracts/l1-contracts/contracts/common/libraries/UnsafeBytes.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {IL1Bridge} from "./interfaces/IL1Bridge.sol";
import {IL2Bridge} from "./interfaces/IL2Bridge.sol";
import {IWithdrawalMessage} from "./interfaces/IWithdrawalMessage.sol";
/**
* @title L1Bridge
* @notice L1 endpoint of the NODL token bridge for zkSync Era.
* @dev Responsibilities:
* - Initiate deposits by enqueuing an L2 call to the counterpart L2 bridge through the Bridgehub.
* - Track deposit tx hashes to enable refunds if an L2 transaction fails.
* - Finalize L2→L1 withdrawals by verifying message inclusion and minting on L1.
* - Secured with Ownable (admin), Pausable (circuit breaker).
*
* Deposits and base-cost quotes go through the Bridgehub ({requestL2TransactionDirect} /
* {l2TransactionBaseCost}), since the Mailbox equivalents are deprecated. The Mailbox (Diamond
* proxy) is still used for the L2→L1 proof paths ({proveL1ToL2TransactionStatus} /
* {proveL2MessageInclusion}), which are not deprecated.
*
* Withdrawal messages carry no nonce — replay protection is this instance's
* {isWithdrawalFinalized} map, which starts empty on a fresh deployment. Since inclusion proofs
* for historical messages remain valid on the Diamond forever, a redeployment must be told about
* its predecessor via {LEGACY_BRIDGE} so withdrawals the old instance already paid out cannot be
* minted a second time here.
*/
contract L1Bridge is Ownable2Step, Pausable, IL1Bridge {
// =============================
// State
// =============================
/// @notice The zkSync Era Mailbox contract on L1 (Diamond proxy). Used for L2→L1 proofs only.
IMailbox public immutable L1_MAILBOX;
/// @notice The zkSync Bridgehub contract on L1. Entry point for deposits and base-cost quotes.
IBridgehub public immutable BRIDGEHUB;
/// @notice The chain id of the target L2, as registered on the Bridgehub.
uint256 public immutable L2_CHAIN_ID;
/// @notice The L1 NODL token instance.
L1Nodl public immutable L1_NODL;
/// @notice The counterpart bridge address deployed on L2.
address public immutable L2_BRIDGE_ADDR;
/// @notice The previous L1 bridge deployment, if any (zero address when this is the first).
/// @dev Withdrawals already finalized by the legacy instance are rejected here, since both
/// instances verify the same L2→L1 messages against the same Diamond.
IL1Bridge public immutable LEGACY_BRIDGE;
/// @notice Per-account mapping of deposit L2 tx hash to deposited amount.
mapping(address account => mapping(bytes32 depositL2TxHash => uint256 amount)) public depositAmount;
/// @notice Tracks whether an L2→L1 message was already finalized to prevent replays.
mapping(uint256 l2BatchNumber => mapping(uint256 l2ToL1MessageNumber => bool isFinalized)) public
isWithdrawalFinalized;
// =============================
// Errors
// =============================
/// @dev Zero address supplied where non-zero is required.
error ZeroAddress();
/// @dev Zero chain id supplied where non-zero is required.
error ZeroChainId();
/// @dev Amount must be greater than zero.
error ZeroAmount();
/// @dev Unknown deposit tx hash for the provided sender.
error UnknownTxHash();
/// @dev Proving a failed L2 tx status did not succeed.
error L2FailureProofFailed();
/// @dev Proving inclusion of an L2→L1 message did not succeed.
error InvalidProof();
/// @dev Withdrawal message length is invalid.
error L2WithdrawalMessageWrongLength(uint256 length);
/// @dev Function selector inside the L2 message is invalid.
error InvalidSelector(bytes4 sel);
/// @dev Withdrawal for the given (batch, index) has already been finalized.
error WithdrawalAlreadyFinalized();
/// @dev Withdrawal for the given (batch, index) was already finalized by the legacy bridge.
error WithdrawalFinalizedOnLegacyBridge();
// =============================
// Constructor
// =============================
/**
* @notice Initializes the bridge with the system Mailbox, Bridgehub, token, and L2 bridge addresses.
* @param _owner The admin address for Ownable controls.
* @param _l1Mailbox The L1 Mailbox (zkSync Era Diamond) proxy address, used for L2→L1 proofs.
* @param _bridgehub The L1 Bridgehub address, used for deposits and base-cost quotes.
* @param _l2ChainId The chain id of the target L2 as registered on the Bridgehub.
* @param _l1Token The L1 NODL token address.
* @param _l2Bridge The L2 bridge contract address.
* @param _legacyBridge The previous L1 bridge deployment whose finalized withdrawals must not
* be replayed here. Zero address when deploying to a chain with no predecessor.
*/
constructor(
address _owner,
address _l1Mailbox,
address _bridgehub,
uint256 _l2ChainId,
address _l1Token,
address _l2Bridge,
address _legacyBridge
) Ownable(_owner) {
if (_l1Mailbox == address(0) || _bridgehub == address(0) || _l1Token == address(0) || _l2Bridge == address(0))
{
revert ZeroAddress();
}
if (_l2ChainId == 0) {
revert ZeroChainId();
}
L1_MAILBOX = IMailbox(_l1Mailbox);
BRIDGEHUB = IBridgehub(_bridgehub);
L2_CHAIN_ID = _l2ChainId;
L1_NODL = L1Nodl(_l1Token);
L2_BRIDGE_ADDR = _l2Bridge;
LEGACY_BRIDGE = IL1Bridge(_legacyBridge);
}
// =============================
// Admin
// =============================
/// @notice Pause state-changing entrypoints guarded by whenNotPaused.
function pause() external onlyOwner {
_pause();
}
/// @notice Unpause the contract to resume normal operations.
function unpause() external onlyOwner {
_unpause();
}
// =============================
// View helpers
// =============================
/**
* @notice Quotes the ETH required to cover the L2 execution cost for a deposit at the current tx.gasprice.
* @dev This is a convenience helper; the actual base cost is a function of the L1 gas price at inclusion time.
* Frontends may prefer {quoteL2BaseCostAtGasPrice} for deterministic quoting.
* @param _l2TxGasLimit Maximum L2 gas the enqueued call can consume.
* @param _l2TxGasPerPubdataByte Gas per pubdata byte limit for the enqueued call.
* @return baseCost The ETH amount that needs to be supplied alongside {deposit}.
*/
function quoteL2BaseCost(uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte)
external
view
returns (uint256 baseCost)
{
baseCost = BRIDGEHUB.l2TransactionBaseCost(L2_CHAIN_ID, tx.gasprice, _l2TxGasLimit, _l2TxGasPerPubdataByte);
}
/**
* @notice Quotes the ETH required to cover the L2 execution cost for a deposit at a specified L1 gas price.
* @param _l1GasPrice The L1 gas price (wei) to use for the quote.
* @param _l2TxGasLimit Maximum L2 gas the enqueued call can consume.
* @param _l2TxGasPerPubdataByte Gas per pubdata byte limit for the enqueued call.
* @return baseCost The ETH amount that needs to be supplied alongside {deposit}.
*/
function quoteL2BaseCostAtGasPrice(uint256 _l1GasPrice, uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte)
external
view
returns (uint256 baseCost)
{
baseCost = BRIDGEHUB.l2TransactionBaseCost(L2_CHAIN_ID, _l1GasPrice, _l2TxGasLimit, _l2TxGasPerPubdataByte);
}
// =============================
// External entrypoints
// =============================
/**
* @notice Initiates a deposit by burning on L1 and enqueuing an L2 finalizeDeposit call.
* @dev Caller must approve/burnable rights on the NODL token and provide msg.value to cover the L2
* transaction base cost. The full msg.value is passed to the Bridgehub as the request's mintValue
* (the Bridgehub requires them to be equal for ETH-based chains); any excess over the actual cost
* is refunded on L2 to the refund recipient.
* @param _l2Receiver The L2 address to receive the bridged tokens.
* @param _amount The amount of tokens to bridge.
* @param _l2TxGasLimit Gas limit for the L2 call.
* @param _l2TxGasPerPubdataByte Gas per pubdata byte for the L2 call.
* @param _refundRecipient Address receiving any ETH refund on L2.
* @return txHash The canonical L2 transaction hash of the enqueued call.
*/
function deposit(
address _l2Receiver,
uint256 _amount,
uint256 _l2TxGasLimit,
uint256 _l2TxGasPerPubdataByte,
address _refundRecipient
) public payable override whenNotPaused returns (bytes32 txHash) {
if (_l2Receiver == address(0)) {
revert ZeroAddress();
}
if (_amount == 0) {
revert ZeroAmount();
}
L1_NODL.burnFrom(msg.sender, _amount);
bytes memory l2Calldata = abi.encodeCall(IL2Bridge.finalizeDeposit, (msg.sender, _l2Receiver, _amount));
address refundRecipient = _refundRecipient != address(0) ? _refundRecipient : msg.sender;
txHash = BRIDGEHUB.requestL2TransactionDirect{value: msg.value}(
L2TransactionRequestDirect({
chainId: L2_CHAIN_ID,
mintValue: msg.value,
l2Contract: L2_BRIDGE_ADDR,
l2Value: 0,
l2Calldata: l2Calldata,
l2GasLimit: _l2TxGasLimit,
l2GasPerPubdataByteLimit: _l2TxGasPerPubdataByte,
factoryDeps: new bytes[](0),
refundRecipient: refundRecipient
})
);
depositAmount[msg.sender][txHash] = _amount;
emit DepositInitiated(txHash, msg.sender, _l2Receiver, _amount);
}
/**
* @notice Convenience overload of {deposit} with refund recipient defaulting to msg.sender.
*/
function deposit(address _l2Receiver, uint256 _amount, uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte)
external
payable
override
returns (bytes32 txHash)
{
return deposit(_l2Receiver, _amount, _l2TxGasLimit, _l2TxGasPerPubdataByte, msg.sender);
}
/**
* @notice Refunds a failed deposit after proving the L2 tx failure via the Mailbox.
* @dev Clears the recorded deposit amount for the given sender and tx hash, then mints back on L1.
* @param _l1Sender The original depositor on L1.
* @param _l2TxHash The L2 tx hash of the failed deposit request.
* @param _l2BatchNumber The batch number containing the failed tx.
* @param _l2MessageIndex The index of the message within the batch.
* @param _l2TxNumberInBatch The transaction number in the batch.
* @param _merkleProof The Merkle proof proving Failure status.
*/
function claimFailedDeposit(
address _l1Sender,
bytes32 _l2TxHash,
uint256 _l2BatchNumber,
uint256 _l2MessageIndex,
uint16 _l2TxNumberInBatch,
bytes32[] calldata _merkleProof
) external override whenNotPaused {
uint256 amount = depositAmount[_l1Sender][_l2TxHash];
if (amount == 0) {
revert UnknownTxHash();
}
bool success = L1_MAILBOX.proveL1ToL2TransactionStatus(
_l2TxHash, _l2BatchNumber, _l2MessageIndex, _l2TxNumberInBatch, _merkleProof, TxStatus.Failure
);
if (!success) {
revert L2FailureProofFailed();
}
delete depositAmount[_l1Sender][_l2TxHash];
L1_NODL.mint(_l1Sender, amount);
emit ClaimedFailedDeposit(_l1Sender, amount);
}
/**
* @notice Finalizes a withdrawal from L2 after proving message inclusion.
* @dev Parses the message payload, verifies inclusion via Mailbox and mints on L1.
* @param _l2BatchNumber The L2 batch number containing the message.
* @param _l2MessageIndex The index of the message within the batch.
* @param _l2TxNumberInBatch The transaction number in the batch.
* @param _message ABI-encoded call data expected by the L1 bridge (finalizeWithdrawal).
* @param _merkleProof The Merkle proof for message inclusion.
*/
function finalizeWithdrawal(
uint256 _l2BatchNumber,
uint256 _l2MessageIndex,
uint16 _l2TxNumberInBatch,
bytes calldata _message,
bytes32[] calldata _merkleProof
) external override whenNotPaused {
if (isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex]) {
revert WithdrawalAlreadyFinalized();
}
if (address(LEGACY_BRIDGE) != address(0) && LEGACY_BRIDGE.isWithdrawalFinalized(_l2BatchNumber, _l2MessageIndex))
{
revert WithdrawalFinalizedOnLegacyBridge();
}
(address l1Receiver, uint256 amount) = _parseL2WithdrawalMessage(_message);
L2Message memory l2ToL1Message =
L2Message({txNumberInBatch: _l2TxNumberInBatch, sender: L2_BRIDGE_ADDR, data: _message});
bool success = L1_MAILBOX.proveL2MessageInclusion({
_batchNumber: _l2BatchNumber,
_index: _l2MessageIndex,
_message: l2ToL1Message,
_proof: _merkleProof
});
if (!success) {
revert InvalidProof();
}
isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex] = true;
L1_NODL.mint(l1Receiver, amount);
emit WithdrawalFinalized(l1Receiver, _l2BatchNumber, _l2MessageIndex, _l2TxNumberInBatch, amount);
}
// =============================
// Internal helpers
// =============================
/**
* @notice Parses and validates the L2→L1 message payload for finalizeWithdrawal.
* @dev Ensures selector matches IWithdrawalMessage.finalizeWithdrawal and reads (receiver, amount).
* @param _l2ToL1message The raw message bytes.
* @return l1Receiver The L1 receiver extracted from the message.
* @return amount The token amount extracted from the message.
*/
function _parseL2WithdrawalMessage(bytes memory _l2ToL1message)
internal
pure
returns (address l1Receiver, uint256 amount)
{
// Require exactly 56 bytes: selector (4) + address (20) + uint256 (32)
if (_l2ToL1message.length != 56) {
revert L2WithdrawalMessageWrongLength(_l2ToL1message.length);
}
// Decode first 56 bytes only; ignore any trailing data
(uint32 functionSignature, uint256 offset) = UnsafeBytes.readUint32(_l2ToL1message, 0);
if (bytes4(functionSignature) != IWithdrawalMessage.finalizeWithdrawal.selector) {
revert InvalidSelector(bytes4(functionSignature));
}
(l1Receiver, offset) = UnsafeBytes.readAddress(_l2ToL1message, offset);
(amount, offset) = UnsafeBytes.readUint256(_l2ToL1message, offset);
}
}